@saulwade/swl-ses 1.4.2 → 1.5.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.
@@ -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 };