@saulwade/swl-ses 2.3.1 → 2.4.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.
Files changed (112) hide show
  1. package/CLAUDE.md +241 -240
  2. package/README.md +597 -597
  3. package/agentes/_intent-spec.md +73 -73
  4. package/agentes/_propose-step.md +90 -90
  5. package/agentes/accesibilidad-wcag-swl.md +690 -690
  6. package/agentes/arquitecto-swl.md +267 -267
  7. package/agentes/auto-evolucion-swl.md +908 -908
  8. package/agentes/backend-api-swl.md +472 -472
  9. package/agentes/backend-csharp-swl.md +420 -420
  10. package/agentes/backend-go-swl.md +390 -390
  11. package/agentes/backend-java-swl.md +281 -281
  12. package/agentes/backend-node-swl.md +479 -479
  13. package/agentes/backend-python-swl.md +605 -605
  14. package/agentes/backend-rust-swl.md +364 -364
  15. package/agentes/backend-workers-swl.md +482 -482
  16. package/agentes/cloud-infra-swl.md +509 -509
  17. package/agentes/consolidador-swl.md +541 -541
  18. package/agentes/datos-swl.md +605 -605
  19. package/agentes/depurador-swl.md +352 -352
  20. package/agentes/devops-ci-swl.md +400 -400
  21. package/agentes/disenador-ui-swl.md +569 -569
  22. package/agentes/documentador-swl.md +345 -345
  23. package/agentes/frontend-angular-swl.md +621 -621
  24. package/agentes/frontend-css-swl.md +716 -716
  25. package/agentes/frontend-react-swl.md +692 -692
  26. package/agentes/frontend-swl.md +496 -496
  27. package/agentes/frontend-tailwind-swl.md +826 -826
  28. package/agentes/gh-fix-ci-swl.md +275 -275
  29. package/agentes/implementador-swl.md +328 -328
  30. package/agentes/investigador-swl.md +432 -432
  31. package/agentes/investigador-ux-swl.md +505 -505
  32. package/agentes/llm-apps-swl.md +278 -278
  33. package/agentes/migrador-swl.md +442 -442
  34. package/agentes/mobile-android-swl.md +511 -511
  35. package/agentes/mobile-cross-swl.md +541 -541
  36. package/agentes/mobile-ios-swl.md +502 -502
  37. package/agentes/mobile-testing-swl.md +302 -302
  38. package/agentes/nemesis-auditor-swl.md +285 -285
  39. package/agentes/notificador-swl.md +918 -918
  40. package/agentes/observabilidad-swl.md +438 -438
  41. package/agentes/orquestador-swl.md +1053 -1026
  42. package/agentes/pagos-swl.md +310 -310
  43. package/agentes/perfilador-usuario-swl.md +321 -321
  44. package/agentes/planificador-swl.md +399 -399
  45. package/agentes/producto-prd-swl.md +589 -589
  46. package/agentes/red-team-swl.md +218 -218
  47. package/agentes/release-manager-swl.md +590 -590
  48. package/agentes/rendimiento-swl.md +713 -713
  49. package/agentes/resolutor-build-swl.md +245 -245
  50. package/agentes/revisor-angular-swl.md +278 -278
  51. package/agentes/revisor-codigo-swl.md +538 -505
  52. package/agentes/revisor-csharp-swl.md +264 -264
  53. package/agentes/revisor-go-swl.md +259 -259
  54. package/agentes/revisor-java-swl.md +257 -257
  55. package/agentes/revisor-kotlin-swl.md +273 -273
  56. package/agentes/revisor-nextjs-swl.md +281 -281
  57. package/agentes/revisor-php-swl.md +271 -271
  58. package/agentes/revisor-react-swl.md +278 -278
  59. package/agentes/revisor-rust-swl.md +346 -346
  60. package/agentes/revisor-seguridad-swl.md +399 -399
  61. package/agentes/revisor-swift-swl.md +268 -268
  62. package/agentes/revisor-typescript-swl.md +346 -346
  63. package/agentes/sre-swl.md +291 -291
  64. package/agentes/tdd-qa-swl.md +393 -393
  65. package/comandos/swl/contexto.md +0 -2
  66. package/comandos/swl/deuda-codigo.md +97 -0
  67. package/habilidades/angular-moderno/SKILL.md +5 -1
  68. package/habilidades/contenedores-docker/SKILL.md +3 -1
  69. package/habilidades/ejecutar-task-iterativo/SKILL.md +278 -278
  70. package/habilidades/harness-claude-code/SKILL.md +4 -4
  71. package/habilidades/harness-claude-code/recursos/disciplina-harness-regla.md +6 -5
  72. package/habilidades/legacy-code-rescue/SKILL.md +2 -1
  73. package/habilidades/meta-skills-estandar/SKILL.md +1 -1
  74. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +2 -2
  75. package/habilidades/postgresql-experto/SKILL.md +3 -1
  76. package/habilidades/prevencion-sobreingenieria/SKILL.md +70 -92
  77. package/habilidades/prevencion-sobreingenieria/recursos/soluciones-nativas.md +166 -0
  78. package/habilidades/prevencion-sobreingenieria/recursos/variables-residuales-post-refactor.md +85 -0
  79. package/hooks/audit-trail.js +4 -4
  80. package/hooks/calidad-pre-commit.js +15 -11
  81. package/hooks/captura-acciones-post.js +3 -2
  82. package/hooks/captura-acciones-session.js +3 -2
  83. package/hooks/contexto-iteracion.js +2 -1
  84. package/hooks/contexto-subagente.js +68 -0
  85. package/hooks/guardrail-modelo.js +13 -3
  86. package/hooks/inyeccion-contexto.js +12 -12
  87. package/hooks/registro-turnos.js +5 -4
  88. package/hooks/spec-gate.js +2 -1
  89. package/hooks/sugerir-contribuir.js +2 -1
  90. package/hooks/sugerir-regenerar-inventario.js +3 -2
  91. package/hooks/tdd-gate.js +2 -1
  92. package/llms.txt +3 -3
  93. package/manifiestos/canonical-hashes.json +667 -10
  94. package/manifiestos/hook-profiles.json +0 -2
  95. package/manifiestos/hooks-config.json +469 -477
  96. package/manifiestos/invariantes-criticos.json +30 -0
  97. package/manifiestos/modulos.json +1390 -1390
  98. package/manifiestos/skills-lock.json +24 -24
  99. package/package.json +93 -93
  100. package/plugin.json +369 -371
  101. package/schemas/agent-frontmatter.schema.json +3 -1
  102. package/scripts/instalador.js +4 -1
  103. package/scripts/lib/expandir-targets.js +71 -71
  104. package/scripts/lib/preservar-usuario.js +39 -5
  105. package/scripts/lib/toml-merge.js +204 -204
  106. package/scripts/mcp-server/auth.js +105 -105
  107. package/scripts/mcp-server/cache.js +106 -106
  108. package/scripts/validar.js +1 -1
  109. package/scripts/verificar-release.js +51 -0
  110. package/hooks/lib/context-compressor.js +0 -657
  111. package/hooks/linea-estado.js +0 -328
  112. package/hooks/monitor-contexto.js +0 -373
@@ -51,6 +51,7 @@
51
51
  "oneOf": [
52
52
  {
53
53
  "enum": [
54
+ "fable",
54
55
  "opus",
55
56
  "sonnet",
56
57
  "haiku",
@@ -61,13 +62,14 @@
61
62
  "pattern": "^claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+){0,2}$"
62
63
  }
63
64
  ],
64
- "description": "Modelo principal del agente. Se recomienda alias corto (opus/sonnet/haiku), que resuelve al modelo vigente de la familia y se auto-actualiza; 'inherit' para sub-agentes; o nombre completo tipo 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5-20251001'."
65
+ "description": "Modelo principal del agente. Se recomienda alias corto (fable/opus/sonnet/haiku), que resuelve al modelo vigente de la familia y se auto-actualiza; 'inherit' para sub-agentes; o nombre completo tipo 'claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5-20251001'."
65
66
  },
66
67
  "modeloAlterno": {
67
68
  "type": "string",
68
69
  "oneOf": [
69
70
  {
70
71
  "enum": [
72
+ "fable",
71
73
  "opus",
72
74
  "sonnet",
73
75
  "haiku",
@@ -26,6 +26,7 @@ const { obtenerTransformador } = require('./lib/transformadores');
26
26
  const {
27
27
  esProtegido,
28
28
  crearBackup,
29
+ rutaBackupSegura,
29
30
  registrarBackup,
30
31
  limpiarBackups,
31
32
  mergeInstintos,
@@ -1245,7 +1246,9 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
1245
1246
  // ALTO (nemesis O6): respaldar el DIRECTORIO COMPLETO (recursos incluidos)
1246
1247
  // antes de sobrescribir — el usuario pudo modificar recursos/ aunque no
1247
1248
  // tocara el SKILL.md. Overwrite controlado en-bloque (no fall-through).
1248
- const backupDir = path.join(process.cwd(), '.planning', 'backups', `v${versionBackup}`, path.relative(process.cwd(), destino));
1249
+ // rutaBackupSegura contiene el backup dentro del dir versionado incluso
1250
+ // cuando destino está fuera del cwd (install --global — bug 2026-07-03).
1251
+ const backupDir = rutaBackupSegura(process.cwd(), versionBackup, destino);
1249
1252
  let backupOk = false;
1250
1253
  try { copiarDirectorio(destino, backupDir); backupOk = true; } catch (e) { console.log(` ⚠ Backup de directorio falló (${e.message})`); }
1251
1254
  if (!backupOk) {
@@ -1,71 +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 };
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 };
@@ -34,7 +34,7 @@ const path = require('path');
34
34
  * - /swl:evolucionar → skills auto-generados en habilidades/
35
35
  * - /swl:crear-skill → skills nuevos en habilidades/
36
36
  * - session-store → .planning/sessions/*.json
37
- * - context-compressor → .planning/COMPACTACION.md
37
+ * - /swl:compactar → .planning/COMPACTACION.md
38
38
  * - cron-store → .planning/cron/
39
39
  * - gateway → .planning/gateway-sessions.json
40
40
  * - agent-comms → .planning/comms/
@@ -181,6 +181,41 @@ function dirBackup(baseDir, version) {
181
181
  return path.join(baseDir, '.planning', 'backups', `v${version}`);
182
182
  }
183
183
 
184
+ /**
185
+ * Subruta segura de un archivo DENTRO del directorio de backups.
186
+ *
187
+ * Un destino fuera de baseDir (install --global: ~/.claude, ~/.openclaude)
188
+ * produce un path.relative con segmentos '..' que ESCAPAN del directorio
189
+ * versionado: `.planning/backups/v2.3.1/../../.claude/...` colapsa a
190
+ * `.planning/.claude/...` (bug 2026-07-03: 1147 backups globales aterrizaron
191
+ * fuera del dir de backups y aparecieron como archivos nuevos en el repo).
192
+ * Se neutralizan '..', '.' y dos puntos de unidad preservando la estructura.
193
+ *
194
+ * @param {string} baseDir - Raíz del proyecto.
195
+ * @param {string} rutaAbsoluta - Ruta absoluta del archivo a respaldar.
196
+ * @returns {string} Subruta relativa contenida (sin '..').
197
+ */
198
+ function subrutaBackupSegura(baseDir, rutaAbsoluta) {
199
+ return path.relative(baseDir, rutaAbsoluta)
200
+ .split(/[\\/]/)
201
+ .filter(s => s && s !== '..' && s !== '.')
202
+ .map(s => s.replace(/:/g, ''))
203
+ .join(path.sep);
204
+ }
205
+
206
+ /**
207
+ * Ruta completa de backup para un archivo, garantizada dentro del
208
+ * directorio versionado de backups.
209
+ *
210
+ * @param {string} baseDir - Raíz del proyecto.
211
+ * @param {string} versionAnterior - Versión que se está sobreescribiendo.
212
+ * @param {string} rutaAbsoluta - Ruta absoluta del archivo a respaldar.
213
+ * @returns {string} Ruta absoluta del backup.
214
+ */
215
+ function rutaBackupSegura(baseDir, versionAnterior, rutaAbsoluta) {
216
+ return path.join(dirBackup(baseDir, versionAnterior), subrutaBackupSegura(baseDir, rutaAbsoluta));
217
+ }
218
+
184
219
  /**
185
220
  * Crea un backup de un archivo antes de sobreescribirlo.
186
221
  *
@@ -195,10 +230,8 @@ function crearBackup(baseDir, rutaAbsoluta, versionAnterior) {
195
230
  }
196
231
 
197
232
  try {
198
- const rutaRelativa = path.relative(baseDir, rutaAbsoluta);
199
- const backupDir = dirBackup(baseDir, versionAnterior);
200
- const rutaBackup = path.join(backupDir, rutaRelativa);
201
- const dirPadre = path.dirname(rutaBackup);
233
+ const rutaBackup = rutaBackupSegura(baseDir, versionAnterior, rutaAbsoluta);
234
+ const dirPadre = path.dirname(rutaBackup);
202
235
 
203
236
  if (!fs.existsSync(dirPadre)) {
204
237
  fs.mkdirSync(dirPadre, { recursive: true });
@@ -573,6 +606,7 @@ module.exports = {
573
606
 
574
607
  // Nivel 2: Backup
575
608
  crearBackup,
609
+ rutaBackupSegura,
576
610
  registrarBackup,
577
611
  listarBackups,
578
612
  restaurarBackup,
@@ -1,204 +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
- };
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
+ };