@saulwade/swl-ses 1.4.2 → 1.5.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.
@@ -411,6 +411,11 @@ async function install(opciones) {
411
411
  perfil,
412
412
  rutaBase: rutas.base,
413
413
  global: esGlobal,
414
+ // Persistir contexto de filtrado de reglas-lenguajes para que doctor
415
+ // verifique conteos contra el filtro real al instalar (no recalcule
416
+ // desde el cwd actual del doctor). Fix v1.4.3.
417
+ allLangs: allLangs === true,
418
+ stackInstalado: stackDetectado instanceof Set ? [...stackDetectado] : null,
414
419
  });
415
420
 
416
421
  let instalados = 0;
@@ -447,6 +452,11 @@ async function install(opciones) {
447
452
  }
448
453
  }
449
454
 
455
+ // Cargar transformador del target ANTES del loop de instalación —
456
+ // Sub-fase 11.5: permite que instalarArchivo invoque transformarAgente
457
+ // por archivo para targets que cambian formato (ej. codex .md → .toml).
458
+ const transformadorTarget = obtenerTransformador(target, runtime);
459
+
450
460
  // Instalar archivos core
451
461
  for (const archivo of resolucion.archivos) {
452
462
  try {
@@ -457,6 +467,7 @@ async function install(opciones) {
457
467
  estado,
458
468
  syncMode,
459
469
  flatNaming,
470
+ transformador: transformadorTarget,
460
471
  });
461
472
  if (resultado.instalado) {
462
473
  registrarArchivo(estado, {
@@ -612,6 +623,13 @@ async function install(opciones) {
612
623
  // transformador Claude pueda detectar stack/comandos/framework.
613
624
  // Otros transformadores ignoran este campo.
614
625
  dirProyecto: process.cwd(),
626
+ // ADR-0019 Sub-fase 1: el transformador Codex necesita saber el scope para
627
+ // resolver dirBase (~/.codex/ vs cwd) y registrar el MCP server.
628
+ // El runtime trae los paths absolutos calculados por detectar-runtime.js.
629
+ esGlobal,
630
+ dirRuntimeGlobal: runtime.global,
631
+ withMcp: Boolean(opciones.with_mcp) && !opciones.no_mcp,
632
+ swlBinPath: path.join(RAIZ_PKG, 'bin', 'swl-mcp-server.js'),
615
633
  });
616
634
 
617
635
  if (instrucciones) {
@@ -628,6 +646,7 @@ async function install(opciones) {
628
646
  console.log(' = CLAUDE.md no modificado (--no-claudemd)');
629
647
  } else if (instrucciones.merge && instrucciones.merge.tipo === 'marcadores') {
630
648
  // Merge idempotente con marcadores — preserva contenido del usuario.
649
+ // Aplica a CLAUDE.md (Claude Code) y AGENTS.md (Codex CLI) — ADR-0019 Sub-fase 1.
631
650
  if (!fs.existsSync(dirInstrucciones)) {
632
651
  fs.mkdirSync(dirInstrucciones, { recursive: true });
633
652
  }
@@ -637,12 +656,13 @@ async function install(opciones) {
637
656
  endTag: instrucciones.merge.endTag,
638
657
  beginPrefix: instrucciones.merge.beginPrefix,
639
658
  });
659
+ const nombreArchivo = instrucciones.rutaRelativa;
640
660
  const etiqueta = {
641
- 'creado': '+ CLAUDE.md creado con bloque SWL',
642
- 'append': '+ Bloque SWL agregado al final de CLAUDE.md (contenido del usuario preservado)',
643
- 'reemplazado': '* Bloque SWL actualizado en CLAUDE.md (contenido del usuario preservado)',
644
- 'sin-cambios': '= CLAUDE.md ya tenía el bloque SWL actualizado',
645
- 'error': '! Error al fusionar CLAUDE.md',
661
+ 'creado': `+ ${nombreArchivo} creado con bloque SWL`,
662
+ 'append': `+ Bloque SWL agregado al final de ${nombreArchivo} (contenido del usuario preservado)`,
663
+ 'reemplazado': `* Bloque SWL actualizado en ${nombreArchivo} (contenido del usuario preservado)`,
664
+ 'sin-cambios': `= ${nombreArchivo} ya tenía el bloque SWL actualizado`,
665
+ 'error': `! Error al fusionar ${nombreArchivo}`,
646
666
  }[resMerge.accion] || `${resMerge.accion} ${resMerge.archivo}`;
647
667
  console.log(` ${etiqueta}${resMerge.detalle ? ': ' + resMerge.detalle : ''}`);
648
668
  registrarArchivo(estado, {
@@ -850,6 +870,33 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
850
870
 
851
871
  let nombreArchivo = path.basename(archivo.origen);
852
872
 
873
+ // Sub-fase 11.5 v1.5.0: para tipo 'agentes', aplicar transformarAgente del
874
+ // transformador del target ANTES de la copia. Permite que codex emita TOML
875
+ // (.toml) en lugar del .md original, o que cursor normalice el frontmatter.
876
+ // Si transformarAgente devuelve `consolidar: true`, saltar — el archivo
877
+ // se incluirá en el archivoPrincipal (AGENTS.md) generado más adelante.
878
+ let contenidoTransformado = null;
879
+ if (archivo.tipo === 'agentes' && opciones.transformador && typeof opciones.transformador.transformarAgente === 'function') {
880
+ try {
881
+ const original = fs.readFileSync(archivo.origen, 'utf-8');
882
+ const t = opciones.transformador.transformarAgente(original, { nombreArchivo });
883
+ if (t && t.consolidar === true) {
884
+ // El transformador del target prefiere consolidar este agente en el
885
+ // archivoPrincipal en lugar de tener archivo individual. No instalar aquí.
886
+ return { instalado: false, razon: 'consolidado en archivoPrincipal' };
887
+ }
888
+ if (t && typeof t.contenido === 'string') {
889
+ contenidoTransformado = t.contenido;
890
+ if (t.rutaRelativa) {
891
+ // Usar el basename del rutaRelativa devuelto (puede cambiar extensión .md → .toml)
892
+ nombreArchivo = path.basename(t.rutaRelativa);
893
+ }
894
+ }
895
+ } catch (err) {
896
+ console.log(` ! Transformación de agente falló (${err.message}), copia literal del .md`);
897
+ }
898
+ }
899
+
853
900
  // Flat naming: convierte rutas jerárquicas en nombres planos con __
854
901
  // Ejemplo: habilidades/build-errors-python → build-errors-python (sin cambio, ya es plano)
855
902
  // Ejemplo: reglas/lenguajes/java/java-estilo.md → java__java-estilo.md
@@ -991,6 +1038,10 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
991
1038
  // Copy mode (default) o fallback de symlink
992
1039
  if (stat.isDirectory()) {
993
1040
  copiarDirectorio(archivo.origen, path.join(dirDestino, nombreArchivo));
1041
+ } else if (contenidoTransformado !== null) {
1042
+ // Sub-fase 11.5: el transformador del target reescribió el contenido
1043
+ // (ej. codex .md → .toml). Escribir el contenido transformado.
1044
+ fs.writeFileSync(destino, contenidoTransformado, 'utf-8');
994
1045
  } else {
995
1046
  fs.copyFileSync(archivo.origen, destino);
996
1047
  }
@@ -81,19 +81,29 @@ const RUNTIMES = {
81
81
  tiposSoportados: ['agentes', 'habilidades', 'comandos', 'reglas', 'hooks'],
82
82
  },
83
83
  codex: {
84
+ // ADR-0019 Sub-fase 1 → ampliada en Sub-fase 8 (mismo v1.5.0).
85
+ // Codex CLI lee:
86
+ // - AGENTS.md jerárquico (project + ~/.codex/AGENTS.md global)
87
+ // - ${CODEX_HOME:-$HOME/.codex}/skills/<name>/SKILL.md (skills propias)
88
+ // - ~/.codex/config.toml con [mcp_servers.NAME] (MCP)
89
+ // - hooks.json con PreToolUse/PostToolUse/Stop (NO mapeado por SWL — formato distinto)
90
+ // Codex NO expone agents/ individual desde filesystem — los agentes SWL
91
+ // van consolidados en AGENTS.md como tabla referencial.
84
92
  nombre: 'Codex CLI',
85
93
  global: path.join(os.homedir(), '.codex'),
86
94
  local: '.codex',
87
- archivosConfig: [],
88
- dirAgentes: null,
89
- dirHabilidades: null,
90
- dirComandos: null,
91
- dirReglas: null,
92
- formatoAgente: 'consolidado',
93
- soportaHooks: false,
94
- soporte: 'parcial', // Solo agentes consolidados y reglas (limitacion de plataforma)
95
+ archivosConfig: ['config.toml'],
96
+ dirAgentes: 'agents', // ~/.codex/agents/<name>.toml — Sub-fase 11 v1.5.0 (formato TOML)
97
+ dirHabilidades: 'skills', // path real `~/.agents/skills/` resuelto por transformador (Sub-fase 10)
98
+ dirComandos: null, // Codex no soporta slash custom filesystem
99
+ dirReglas: null, // consolidado en AGENTS.md (Codex rules son Starlark, no portable)
100
+ formatoAgente: 'toml', // Sub-fase 11: cada agente es un .toml individual
101
+ soportaHooks: true, // Sub-fase 10: ~/.codex/hooks.json (6 eventos)
102
+ hookConfig: 'hooks.json',
103
+ soporte: 'completo',
95
104
  archivoPrincipal: 'AGENTS.md',
96
- tiposSoportados: ['agentes', 'reglas'],
105
+ tiposSoportados: ['agentes', 'habilidades', 'reglas', 'hooks'],
106
+ notas: 'Agentes en ~/.codex/agents/<name>.toml (formato TOML — Sub-fase 11). Skills en ~/.agents/skills/ (path oficial OpenAI — Sub-fase 10). Hooks en ~/.codex/hooks.json. MCP server en ~/.codex/config.toml. AGENTS.md sigue conteniendo tabla referencial como índice.',
97
107
  },
98
108
  gemini: {
99
109
  nombre: 'Gemini CLI',
@@ -111,6 +121,34 @@ const RUNTIMES = {
111
121
  archivoPrincipal: 'GEMINI.md',
112
122
  tiposSoportados: ['agentes', 'habilidades', 'comandos', 'reglas', 'hooks'],
113
123
  },
124
+ cursor: {
125
+ // ADR-0019 Sub-fase 2 → refinada en Sub-fase 7 (mismo v1.5.0).
126
+ // Tras verificar docs oficiales de Cursor (cursor.com/es/docs/{subagents,skills,rules,mcp}):
127
+ // Cursor SÍ soporta subagents y skills filesystem nativamente.
128
+ // - `.cursor/agents/<name>.md` con frontmatter Claude-compatible
129
+ // (name, description, model=inherit, readonly, is_background).
130
+ // Legacy: lee `.claude/agents/`, `.codex/agents/`.
131
+ // - `.cursor/skills/<name>/SKILL.md` (mismo formato Anthropic Skills).
132
+ // Legacy: lee `.agents/skills/`, `.claude/skills/`, `.codex/skills/`.
133
+ // - `.cursor/rules/*.mdc` con frontmatter description/alwaysApply/globs.
134
+ // - `.cursor/mcp.json` o `~/.cursor/mcp.json` para MCP servers.
135
+ // - NO soporta nativamente: slash commands custom, hooks (PreToolUse, etc).
136
+ nombre: 'Cursor',
137
+ global: path.join(os.homedir(), '.cursor'),
138
+ local: '.cursor',
139
+ archivosConfig: ['mcp.json'],
140
+ dirAgentes: 'agents', // .cursor/agents/<name>.md
141
+ dirHabilidades: 'skills', // .cursor/skills/<name>/SKILL.md
142
+ dirComandos: null, // Cursor no tiene slash custom filesystem (skills cumplen ese rol)
143
+ dirReglas: 'rules', // .cursor/rules/*.mdc
144
+ formatoAgente: 'markdown-frontmatter',
145
+ soportaHooks: true, // Sub-fase 10: .cursor/hooks.json (17 eventos)
146
+ hookConfig: 'hooks.json',
147
+ soporte: 'completo',
148
+ archivoPrincipal: null,
149
+ tiposSoportados: ['agentes', 'habilidades', 'reglas', 'hooks'],
150
+ notas: 'Subagents en .cursor/agents/, skills en .cursor/skills/<name>/SKILL.md, reglas en .cursor/rules/*.mdc, hooks en .cursor/hooks.json (17 eventos camelCase), MCP en .cursor/mcp.json. Slash commands custom: usar skills (se invocan con /<name>).',
151
+ },
114
152
  };
115
153
 
116
154
  /**
@@ -269,6 +307,33 @@ function detectarInstalacionesDuales(runtimeId, opciones = {}) {
269
307
  };
270
308
  }
271
309
 
310
+ /**
311
+ * Lista los IDs de runtimes "instalables" — es decir, los que tienen
312
+ * transformador disponible y soporte declarado (completo o parcial).
313
+ *
314
+ * Se usa para expandir `--all-runtimes` en multi-target install (ADR-0019 Sub-fase 2.5).
315
+ *
316
+ * Excluye alias (openclaude comparte el dir .claude/ con claude — incluirlo en
317
+ * --all-runtimes produciría doble instalación al mismo destino).
318
+ *
319
+ * Orden estable: el orden de la lista importa porque define el orden de
320
+ * ejecución de --all-runtimes. Si un target falla los siguientes igual se
321
+ * intentan (ADR-0019 punto 9: atomicidad por target, no rollback).
322
+ *
323
+ * @returns {string[]} Lista de runtime IDs ordenada de "más usado" a "menos usado".
324
+ */
325
+ function listarRuntimesInstalables() {
326
+ return [
327
+ 'claude', // Soporte completo, primer ciudadano
328
+ 'cursor', // Soporte parcial — reglas + MCP
329
+ 'codex', // Soporte parcial — AGENTS.md + MCP (v1.5.0+)
330
+ 'opencode', // Soporte completo
331
+ 'gemini', // Soporte completo
332
+ 'copilot', // Soporte parcial — agentes consolidados
333
+ // 'openclaude' intencionalmente excluido — comparte dir con claude
334
+ ];
335
+ }
336
+
272
337
  module.exports = {
273
338
  RUNTIMES,
274
339
  detectarRuntimes,
@@ -276,4 +341,5 @@ module.exports = {
276
341
  obtenerRuntime,
277
342
  calcularRutas,
278
343
  resumenDeteccion,
344
+ listarRuntimesInstalables,
279
345
  };
@@ -21,7 +21,17 @@ try {
21
21
  const NOMBRE_ESTADO = '.swl-install-state.json';
22
22
 
23
23
  /**
24
- * Crea un nuevo registro de estado de instalación
24
+ * Crea un nuevo registro de estado de instalación.
25
+ *
26
+ * Persiste el contexto de filtrado de reglas-lenguajes (allLangs, stackInstalado)
27
+ * para que doctor verifique conteos contra el filtro real usado al instalar,
28
+ * no contra un nuevo filtro calculado desde el cwd actual del doctor.
29
+ *
30
+ * Bug histórico v1.4.2: doctor reportaba "reglas: 65 (perfil esperaba 25)" como
31
+ * falso positivo cuando el usuario instalaba globalmente desde un dir con
32
+ * indicadores de lenguaje y luego ejecutaba doctor desde otro dir sin esos
33
+ * indicadores. El recálculo del filtro daba 25 esperadas en lugar de las 65
34
+ * realmente instaladas.
25
35
  */
26
36
  function crearEstado(opciones) {
27
37
  return {
@@ -37,6 +47,8 @@ function crearEstado(opciones) {
37
47
  componentesExternos: [],
38
48
  hooksRegistrados: 0,
39
49
  settingsModificado: false,
50
+ allLangs: opciones.allLangs === true,
51
+ stackInstalado: Array.isArray(opciones.stackInstalado) ? opciones.stackInstalado : null,
40
52
  instaladoEn: new Date().toISOString(),
41
53
  actualizadoEn: new Date().toISOString(),
42
54
  };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Expansión de --target (CSV) y --all-runtimes para multi-target install.
5
+ *
6
+ * ADR-0019 Sub-fase 2.5.
7
+ *
8
+ * Extraído de bin/swl-ses.js para permitir tests unitarios. NUNCA debe tener
9
+ * side effects fuera de logging — solo manipula la lista de strings.
10
+ *
11
+ * @module scripts/lib/expandir-targets
12
+ */
13
+
14
+ const { listarRuntimesInstalables, RUNTIMES } = require('./detectar-runtime');
15
+
16
+ /**
17
+ * Expande las opciones del CLI a un array de target IDs.
18
+ *
19
+ * Reglas:
20
+ * - `all_runtimes` tiene prioridad sobre `target`. Si ambos vienen, se loggea
21
+ * aviso (vía `logger.warn`) y se usa `all_runtimes`.
22
+ * - `target='a,b,c'` → ['a','b','c'].
23
+ * - `target='claude'` → ['claude'].
24
+ * - Sin nada → ['claude'] (default histórico, backward-compat).
25
+ * - Duplicados se deduplican preservando el primer orden.
26
+ * - Targets desconocidos se omiten con aviso (logger.warn).
27
+ *
28
+ * @param {object} opciones - Objeto con `target` (string|undefined) y `all_runtimes` (bool).
29
+ * @param {object} [logger] - Sustituible para tests. Default console.
30
+ * @returns {{ targets: string[], omitidos: string[], errores: string[] }}
31
+ */
32
+ function expandirTargets(opciones, logger) {
33
+ const log = logger || console;
34
+ const omitidos = [];
35
+ const errores = [];
36
+
37
+ let candidatos;
38
+ if (opciones.all_runtimes) {
39
+ if (opciones.target && typeof opciones.target === 'string') {
40
+ log.warn && log.warn('[expandir-targets] --all-runtimes tiene prioridad sobre --target; se ignora --target.');
41
+ }
42
+ candidatos = listarRuntimesInstalables();
43
+ } else if (typeof opciones.target === 'string' && opciones.target.includes(',')) {
44
+ candidatos = opciones.target.split(',').map(s => s.trim()).filter(Boolean);
45
+ } else if (typeof opciones.target === 'string' && opciones.target.length > 0) {
46
+ candidatos = [opciones.target.trim()];
47
+ } else {
48
+ candidatos = ['claude']; // backward-compat
49
+ }
50
+
51
+ const visto = new Set();
52
+ const targets = [];
53
+ for (const t of candidatos) {
54
+ if (visto.has(t)) continue;
55
+ visto.add(t);
56
+ if (!RUNTIMES[t]) {
57
+ log.warn && log.warn(`[expandir-targets] Target desconocido omitido: "${t}". Disponibles: ${Object.keys(RUNTIMES).join(', ')}`);
58
+ omitidos.push(t);
59
+ continue;
60
+ }
61
+ targets.push(t);
62
+ }
63
+
64
+ if (targets.length === 0) {
65
+ errores.push('Ningún target válido tras expansión.');
66
+ }
67
+
68
+ return { targets, omitidos, errores };
69
+ }
70
+
71
+ module.exports = { expandirTargets };
@@ -42,6 +42,9 @@ const BOOLEANAS = [
42
42
  'dry-run', 'simular',
43
43
  'force', 'forzar',
44
44
  'verbose', 'all', 'all-langs', 'no-claudemd',
45
+ // ADR-0019 — Codex/Cursor + MCP autoconfig opt-in
46
+ 'with-mcp', 'no-mcp',
47
+ 'all-runtimes',
45
48
  ];
46
49
 
47
50
  /**
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Merge mínimo de secciones TOML — zero-deps.
5
+ *
6
+ * Opera sobre el formato concreto de `~/.codex/config.toml` (OpenAI Codex CLI):
7
+ * [mcp_servers.<name>]
8
+ * command = "node"
9
+ * args = ["...", "..."]
10
+ * [mcp_servers.<name>.env]
11
+ * KEY = "value"
12
+ *
13
+ * Reglas:
14
+ * - NUNCA reescribe el archivo entero. Solo agrega o reemplaza la sección
15
+ * `[mcp_servers.<name>]` indicada (incluyendo sub-tabla `.env`).
16
+ * - Si el archivo no existe lo crea con permisos 0600.
17
+ * - Si el archivo tiene comentarios o secciones desconocidas, las preserva.
18
+ * - Bloque "owned" delimitado por marcadores `# SWL-MCP-BEGIN <name>` /
19
+ * `# SWL-MCP-END <name>` para permitir actualizaciones idempotentes.
20
+ *
21
+ * NO soporta:
22
+ * - TOML completo (arrays multilínea complejos, inline tables, etc.)
23
+ * - Multi-section nested tables fuera de `mcp_servers.*`
24
+ *
25
+ * Si en el futuro Codex publica un comando idempotente `codex mcp add --idempotent`
26
+ * podemos delegarle el trabajo y deprecar este helper.
27
+ *
28
+ * @module scripts/lib/toml-merge
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const { atomicWriteSync } = require('../../hooks/lib/atomic-write');
35
+
36
+ const BEGIN_PREFIX = '# SWL-MCP-BEGIN';
37
+ const END_PREFIX = '# SWL-MCP-END';
38
+
39
+ /**
40
+ * Serializa un servidor MCP en bloque TOML.
41
+ *
42
+ * @param {string} name - Nombre del server (identificador en config.toml).
43
+ * @param {{ command: string, args?: string[], env?: Record<string,string> }} cfg
44
+ * @returns {string} Bloque TOML con marcadores.
45
+ */
46
+ function serializarServidor(name, cfg) {
47
+ if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(name)) {
48
+ throw new Error(`Nombre de MCP server inválido: "${name}". Solo [a-zA-Z0-9_-].`);
49
+ }
50
+ if (!cfg || typeof cfg.command !== 'string' || cfg.command.length === 0) {
51
+ throw new Error(`MCP server "${name}" requiere "command" string no vacío.`);
52
+ }
53
+
54
+ const lineas = [];
55
+ lineas.push(`${BEGIN_PREFIX} ${name}`);
56
+ lineas.push(`[mcp_servers.${name}]`);
57
+ lineas.push(`command = ${tomlString(cfg.command)}`);
58
+
59
+ if (Array.isArray(cfg.args) && cfg.args.length > 0) {
60
+ const items = cfg.args.map(tomlString).join(', ');
61
+ lineas.push(`args = [${items}]`);
62
+ }
63
+
64
+ if (cfg.env && typeof cfg.env === 'object' && Object.keys(cfg.env).length > 0) {
65
+ lineas.push('');
66
+ lineas.push(`[mcp_servers.${name}.env]`);
67
+ for (const [k, v] of Object.entries(cfg.env)) {
68
+ if (typeof v !== 'string') {
69
+ throw new Error(`MCP server "${name}" env.${k} debe ser string (recibido: ${typeof v}).`);
70
+ }
71
+ // Solo claves alfanuméricas/_ en env names — TOML restricción razonable
72
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
73
+ throw new Error(`MCP server "${name}" env key inválido: "${k}".`);
74
+ }
75
+ lineas.push(`${k} = ${tomlString(v)}`);
76
+ }
77
+ }
78
+
79
+ lineas.push(`${END_PREFIX} ${name}`);
80
+ return lineas.join('\n');
81
+ }
82
+
83
+ /**
84
+ * Escapa un string para TOML usando comillas dobles.
85
+ * No usa basic-string raw para evitar pitfalls con backslashes.
86
+ */
87
+ function tomlString(s) {
88
+ // TOML basic string: escape backslash, doblequote, control chars.
89
+ const escapado = String(s)
90
+ .replace(/\\/g, '\\\\')
91
+ .replace(/"/g, '\\"')
92
+ .replace(/\n/g, '\\n')
93
+ .replace(/\r/g, '\\r')
94
+ .replace(/\t/g, '\\t');
95
+ return `"${escapado}"`;
96
+ }
97
+
98
+ /**
99
+ * Inserta o actualiza un servidor MCP en `config.toml`.
100
+ *
101
+ * Idempotente: si el bloque ya existe con el mismo contenido, no escribe.
102
+ * Si existe con contenido distinto, lo reemplaza dentro de los marcadores.
103
+ * Si no existe, lo agrega al final del archivo precedido de línea en blanco.
104
+ *
105
+ * @param {string} rutaConfig - Path absoluto a config.toml (típicamente ~/.codex/config.toml).
106
+ * @param {string} name - Nombre del MCP server (clave de [mcp_servers.<name>]).
107
+ * @param {{ command: string, args?: string[], env?: Record<string,string> }} cfg
108
+ * @returns {{ accion: 'creado' | 'agregado' | 'actualizado' | 'sin-cambios', ruta: string }}
109
+ */
110
+ function upsertMcpServer(rutaConfig, name, cfg) {
111
+ const bloqueNuevo = serializarServidor(name, cfg);
112
+
113
+ let existente = '';
114
+ let existia = false;
115
+ try {
116
+ existente = fs.readFileSync(rutaConfig, 'utf-8');
117
+ existia = true;
118
+ } catch (err) {
119
+ if (err.code !== 'ENOENT') throw err;
120
+ // Archivo no existe — se crea
121
+ }
122
+
123
+ const beginTag = `${BEGIN_PREFIX} ${name}`;
124
+ const endTag = `${END_PREFIX} ${name}`;
125
+
126
+ if (!existia) {
127
+ const dir = path.dirname(rutaConfig);
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ const contenido = `# config.toml de Codex CLI — gestionado parcialmente por swl-ses\n# Bloques delimitados por SWL-MCP-BEGIN/END son regenerables.\n\n${bloqueNuevo}\n`;
130
+ atomicWriteSync(rutaConfig, contenido, 'utf8', { mode: 0o600 });
131
+ return { accion: 'creado', ruta: rutaConfig };
132
+ }
133
+
134
+ const idxBegin = existente.indexOf(beginTag);
135
+ const idxEnd = existente.indexOf(endTag);
136
+
137
+ // Caso 1: no existe el bloque → append al final
138
+ if (idxBegin === -1 || idxEnd === -1) {
139
+ const separador = existente.endsWith('\n') ? '\n' : '\n\n';
140
+ const contenido = existente + separador + bloqueNuevo + '\n';
141
+ atomicWriteSync(rutaConfig, contenido, 'utf8', { mode: 0o600 });
142
+ return { accion: 'agregado', ruta: rutaConfig };
143
+ }
144
+
145
+ // Caso 2: existe — comparar contenido
146
+ // Calculamos fin REAL incluyendo el endTag completo.
147
+ const finBloqueExistente = idxEnd + endTag.length;
148
+ const bloqueExistente = existente.slice(idxBegin, finBloqueExistente);
149
+
150
+ if (bloqueExistente === bloqueNuevo) {
151
+ return { accion: 'sin-cambios', ruta: rutaConfig };
152
+ }
153
+
154
+ const contenido = existente.slice(0, idxBegin) + bloqueNuevo + existente.slice(finBloqueExistente);
155
+ atomicWriteSync(rutaConfig, contenido, 'utf8', { mode: 0o600 });
156
+ return { accion: 'actualizado', ruta: rutaConfig };
157
+ }
158
+
159
+ /**
160
+ * Elimina un bloque MCP server por nombre. Idempotente: si no existe, no hace nada.
161
+ *
162
+ * @param {string} rutaConfig
163
+ * @param {string} name
164
+ * @returns {{ accion: 'eliminado' | 'sin-cambios', ruta: string }}
165
+ */
166
+ function removeMcpServer(rutaConfig, name) {
167
+ let existente = '';
168
+ try {
169
+ existente = fs.readFileSync(rutaConfig, 'utf-8');
170
+ } catch (err) {
171
+ if (err.code === 'ENOENT') return { accion: 'sin-cambios', ruta: rutaConfig };
172
+ throw err;
173
+ }
174
+
175
+ const beginTag = `${BEGIN_PREFIX} ${name}`;
176
+ const endTag = `${END_PREFIX} ${name}`;
177
+ const idxBegin = existente.indexOf(beginTag);
178
+ const idxEnd = existente.indexOf(endTag);
179
+
180
+ if (idxBegin === -1 || idxEnd === -1) {
181
+ return { accion: 'sin-cambios', ruta: rutaConfig };
182
+ }
183
+
184
+ // Quitar bloque + posible newline previo y posterior (cosmético)
185
+ let inicio = idxBegin;
186
+ let fin = idxEnd + endTag.length;
187
+ // Comer un \n previo si existe (no comer doble \n para preservar separadores entre secciones)
188
+ if (inicio > 0 && existente[inicio - 1] === '\n') inicio--;
189
+ if (fin < existente.length && existente[fin] === '\n') fin++;
190
+
191
+ const contenido = existente.slice(0, inicio) + existente.slice(fin);
192
+ atomicWriteSync(rutaConfig, contenido, 'utf8', { mode: 0o600 });
193
+ return { accion: 'eliminado', ruta: rutaConfig };
194
+ }
195
+
196
+ module.exports = {
197
+ upsertMcpServer,
198
+ removeMcpServer,
199
+ serializarServidor,
200
+ // Para tests
201
+ _tomlString: tomlString,
202
+ BEGIN_PREFIX,
203
+ END_PREFIX,
204
+ };
@@ -8,27 +8,61 @@ class TransformadorBase {
8
8
  this.runtime = runtimeConfig;
9
9
  }
10
10
 
11
- // Parsea frontmatter YAML de un archivo markdown
11
+ // Parsea frontmatter YAML de un archivo markdown.
12
+ // Maneja CRLF (Windows) y LF (Unix). Soporta valores multi-línea con `>` y `|`.
12
13
  // Retorna { frontmatter: object, cuerpo: string }
13
14
  parsearFrontmatter(contenido) {
14
- const match = contenido.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
15
+ // Normalizar a LF para que el regex funcione con archivos Windows (CRLF).
16
+ const norm = String(contenido).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
17
+ const match = norm.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
15
18
  if (!match) return { frontmatter: {}, cuerpo: contenido };
16
19
 
17
20
  const yamlStr = match[1];
18
21
  const cuerpo = match[2];
19
22
  const frontmatter = {};
20
23
 
21
- for (const linea of yamlStr.split('\n')) {
22
- const idx = linea.indexOf(':');
23
- if (idx === -1) continue;
24
- const clave = linea.slice(0, idx).trim();
25
- let valor = linea.slice(idx + 1).trim();
26
- // Booleans
24
+ const lineas = yamlStr.split('\n');
25
+ let i = 0;
26
+ while (i < lineas.length) {
27
+ const linea = lineas[i];
28
+ const idxColon = linea.indexOf(':');
29
+ if (idxColon === -1) { i++; continue; }
30
+ const clave = linea.slice(0, idxColon).trim();
31
+ let valorRaw = linea.slice(idxColon + 1).trim();
32
+
33
+ // Multi-línea con `>` (folded) o `|` (literal):
34
+ // capturar líneas indentadas siguientes y colapsarlas a single-line para `>`,
35
+ // mantener saltos para `|`. Soporta el patrón estándar de YAML en SWL agents.
36
+ if (valorRaw === '>' || valorRaw === '|') {
37
+ const folded = valorRaw === '>';
38
+ const partes = [];
39
+ i++;
40
+ while (i < lineas.length) {
41
+ const sig = lineas[i];
42
+ // Línea no indentada y no vacía → fin del bloque multi-línea.
43
+ if (sig.length > 0 && !/^\s/.test(sig)) break;
44
+ // Línea vacía dentro del bloque → mantener como separador.
45
+ if (sig.trim().length === 0) {
46
+ partes.push('');
47
+ i++;
48
+ continue;
49
+ }
50
+ partes.push(sig.replace(/^\s+/, ''));
51
+ i++;
52
+ }
53
+ frontmatter[clave] = folded
54
+ ? partes.filter(p => p.length > 0).join(' ').trim()
55
+ : partes.join('\n').trim();
56
+ continue;
57
+ }
58
+
59
+ // Valor inline simple
60
+ let valor = valorRaw;
27
61
  if (valor === 'true') valor = true;
28
62
  else if (valor === 'false') valor = false;
29
- // Numbers
30
63
  else if (/^\d+$/.test(valor)) valor = parseInt(valor, 10);
31
64
  frontmatter[clave] = valor;
65
+ i++;
32
66
  }
33
67
 
34
68
  return { frontmatter, cuerpo };