@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.
- package/CLAUDE.md +209 -208
- package/README.md +561 -560
- package/bin/swl-mcp-server.js +214 -187
- package/bin/swl-ses.js +74 -0
- package/comandos/swl/ejecutar-fase.md +33 -4
- package/comandos/swl/metricas.md +72 -0
- package/habilidades/discutir-fase/SKILL.md +50 -2
- package/habilidades/ejecutar-task-iterativo/SKILL.md +278 -0
- package/habilidades/meta-skills-estandar/SKILL.md +22 -1
- package/habilidades/node-experto/SKILL.md +13 -2
- package/habilidades/protocolo-revision-swl/SKILL.md +276 -0
- package/habilidades/tdd-workflow/SKILL.md +33 -4
- package/habilidades/verificar-trabajo/SKILL.md +54 -5
- package/manifiestos/modulos.json +1321 -1267
- package/package.json +3 -3
- package/plugin.json +351 -351
- package/scripts/derivar-feature-list.js +489 -0
- package/scripts/doctor.js +62 -8
- package/scripts/instalador.js +56 -5
- package/scripts/lib/detectar-runtime.js +75 -9
- package/scripts/lib/estado.js +13 -1
- package/scripts/lib/expandir-targets.js +71 -0
- package/scripts/lib/parsear-opciones.js +3 -0
- package/scripts/lib/toml-merge.js +204 -0
- package/scripts/lib/transformadores/base.js +43 -9
- package/scripts/lib/transformadores/codex.js +375 -115
- package/scripts/lib/transformadores/cursor.js +359 -0
- package/scripts/lib/transformadores/index.js +2 -0
- package/scripts/mcp-server/README.md +170 -128
- package/scripts/mcp-server/auth.js +105 -0
- package/scripts/mcp-server/cache.js +106 -0
- package/scripts/mcp-server/handlers.js +190 -10
- package/scripts/mcp-server/telemetry.js +78 -0
package/scripts/instalador.js
CHANGED
|
@@ -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':
|
|
642
|
-
'append':
|
|
643
|
-
'reemplazado':
|
|
644
|
-
'sin-cambios':
|
|
645
|
-
'error':
|
|
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:
|
|
89
|
-
dirHabilidades:
|
|
90
|
-
dirComandos: null,
|
|
91
|
-
dirReglas: null,
|
|
92
|
-
formatoAgente: '
|
|
93
|
-
soportaHooks:
|
|
94
|
-
|
|
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
|
};
|
package/scripts/lib/estado.js
CHANGED
|
@@ -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 };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
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 };
|