ratifact 1.0.0__py3-none-any.whl

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 (50) hide show
  1. ratifact/__init__.py +7 -0
  2. ratifact/app.py +202 -0
  3. ratifact/archivos.py +98 -0
  4. ratifact/build.py +227 -0
  5. ratifact/busqueda.py +134 -0
  6. ratifact/catalogo.py +138 -0
  7. ratifact/cli.py +67 -0
  8. ratifact/clientes.py +206 -0
  9. ratifact/clientes_toml.py +210 -0
  10. ratifact/config.py +264 -0
  11. ratifact/delegacion.py +175 -0
  12. ratifact/doctor.py +299 -0
  13. ratifact/esquema.py +204 -0
  14. ratifact/herramientas/__init__.py +3 -0
  15. ratifact/herramientas/contexto_documentos.py +280 -0
  16. ratifact/herramientas/decisiones.py +261 -0
  17. ratifact/herramientas/pendientes_sesiones.py +78 -0
  18. ratifact/herramientas/proyectos.py +300 -0
  19. ratifact/herramientas/tareas.py +286 -0
  20. ratifact/herramientas/tareas_brief.py +184 -0
  21. ratifact/informe.py +182 -0
  22. ratifact/informe_memoria.py +279 -0
  23. ratifact/instalacion.py +247 -0
  24. ratifact/instalar.py +262 -0
  25. ratifact/instalar_extras.py +193 -0
  26. ratifact/integraciones/__init__.py +3 -0
  27. ratifact/integraciones/base.py +24 -0
  28. ratifact/integraciones/codegraph.py +19 -0
  29. ratifact/integraciones/engram.py +14 -0
  30. ratifact/integraciones/gentle_ai.py +23 -0
  31. ratifact/integraciones/registro.py +108 -0
  32. ratifact/listado.py +85 -0
  33. ratifact/migracion_v2.py +206 -0
  34. ratifact/modelos.py +131 -0
  35. ratifact/plantillas.py +51 -0
  36. ratifact/procesos.py +168 -0
  37. ratifact/prompts.py +29 -0
  38. ratifact/registros.py +258 -0
  39. ratifact/retirado.py +88 -0
  40. ratifact/rutas.py +73 -0
  41. ratifact/skills/run-task/SKILL.md +56 -0
  42. ratifact/upgrade.py +195 -0
  43. ratifact/upgrade_aplicar.py +185 -0
  44. ratifact/vocabulario.py +82 -0
  45. ratifact/workers.py +197 -0
  46. ratifact-1.0.0.dist-info/METADATA +284 -0
  47. ratifact-1.0.0.dist-info/RECORD +50 -0
  48. ratifact-1.0.0.dist-info/WHEEL +4 -0
  49. ratifact-1.0.0.dist-info/entry_points.txt +2 -0
  50. ratifact-1.0.0.dist-info/licenses/LICENSE +21 -0
ratifact/catalogo.py ADDED
@@ -0,0 +1,138 @@
1
+ """Catálogo de la memoria (ADR-022): id, tipo, título, estado y ruta de cada registro de .project/decisions,
2
+ pending y tasks, leído del frontmatter en cada llamada. No se guarda nada en disco: sin índice, sin caché, sin
3
+ watcher. Alimenta la resolución de ids de leer_documento, el listado de registros, los resultados de buscar y
4
+ los pendientes abiertos de obtener_contexto."""
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import unicodedata
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+
12
+ from ratifact.config import MEM, ErrorMemoria, _mem
13
+ from ratifact.registros import _buscar_registro, _clave_id, _leer_registro, _normalizar_id
14
+ from ratifact.vocabulario import CARPETAS_V1, DECISIONES, PENDIENTES, TAREAS
15
+
16
+ # prefijo del id → (carpeta de la memoria, tipo)
17
+ TIPOS = {"ADR": (DECISIONES, "decision"), "P": (PENDIENTES, "pending"), "T": (TAREAS, "task")}
18
+ CARPETAS = {carpeta: prefijo for prefijo, (carpeta, _) in TIPOS.items()}
19
+ # un id pedido con la carpeta v1 ("tareas/T-024.md") se resuelve igual (solo lectura, ADR-022)
20
+ CARPETAS_ID = {**CARPETAS, **{v1: CARPETAS[v2] for v1, v2 in CARPETAS_V1.items() if v2 in CARPETAS}}
21
+
22
+
23
+ class Registro(NamedTuple):
24
+ id: str
25
+ tipo: str
26
+ titulo: str
27
+ estado: str
28
+ ruta: str # relativa al proyecto: ".project/<carpeta>/<archivo>"
29
+ fecha: str = "" # fecha de creación del frontmatter (AAAA-MM-DD)
30
+
31
+
32
+ def _registros_de(mem: Path, prefijo: str) -> list[Registro]:
33
+ carpeta, tipo = TIPOS[prefijo]
34
+ d = mem / carpeta
35
+ filas = []
36
+ for p in sorted(d.glob(f"{prefijo}-*.md"), key=lambda a: _clave_id(a.name)) if d.is_dir() else []:
37
+ if not re.match(rf"{prefijo}-\d+", p.name):
38
+ continue
39
+ meta, _ = _leer_registro(p)
40
+ id_ = _normalizar_id(meta.get("id") or re.match(rf"{prefijo}-\d+", p.name).group(0))
41
+ filas.append(Registro(id_, tipo, meta.get("title") or meta.get("topic", ""), meta.get("status", ""),
42
+ f"{MEM}/{carpeta}/{p.name}", meta.get("date", "")))
43
+ return filas
44
+
45
+
46
+ def _catalogo(proyecto: str) -> list[Registro]:
47
+ """Todos los registros del proyecto (decisiones, pendientes, tareas), cada tipo en orden numérico de id."""
48
+ mem = _mem(proyecto)
49
+ return [r for prefijo in TIPOS for r in _registros_de(mem, prefijo)]
50
+
51
+
52
+ def _linea(r: Registro, separador: str = " ") -> str:
53
+ return f"{r.id} · {r.estado} · {r.titulo}{separador}({r.ruta})"
54
+
55
+
56
+ TOPE_TEMA = 100 # caracteres del tema de un pendiente en el arranque; más largo se corta a 99 + "…"
57
+
58
+
59
+ def _bloque_pendientes(proyecto: str) -> str:
60
+ """Pendientes abiertos para obtener_contexto (ADR-026): una línea por pendiente, en orden numérico de id, desde el
61
+ catálogo (sin extracto ni dueño). OPEN.md sigue en disco con su formato; el arranque ya no lo incluye."""
62
+ abiertos = [r for r in _registros_en(proyecto, "P") if r.estado == "open"]
63
+ lineas = [f"# Open items ({len(abiertos)})"]
64
+ for r in abiertos:
65
+ tema = r.titulo if len(r.titulo) <= TOPE_TEMA else r.titulo[:TOPE_TEMA - 1] + "…"
66
+ lineas.append(f"- {r.id} · {tema} ({r.fecha})")
67
+ if abiertos:
68
+ lineas.append(f'Detail: read_document("{proyecto}", "P-NNN")')
69
+ return "\n".join(lineas)
70
+
71
+
72
+ def _id_de_ruta(ruta: str) -> str | None:
73
+ """Id que se pidió con una ruta que no existe: "T-24", "T-024.md", "tasks/T-024.md", ".project/tasks/T-024.md",
74
+ ".project/decisions/ADR-020-<slug equivocado>.md" (también con la carpeta v1: "tareas/T-024.md"). Solo si el resto
75
+ de la ruta apunta a la memoria (nada, una carpeta de registros, .project o .project/<carpeta de registros>) y el
76
+ prefijo es de un tipo conocido; así una ruta de código como "docs/T-001.md" o "src/T-1.java" nunca se resuelve a un registro."""
77
+ partes = Path(ruta.strip()).parts
78
+ if not partes:
79
+ return None
80
+ padre = partes[:-1]
81
+ if padre and padre[0] == MEM:
82
+ padre = padre[1:]
83
+ if len(padre) > 1 or (padre and padre[0] not in CARPETAS_ID):
84
+ return None
85
+ m = re.fullmatch(r"([A-Za-z]+-\d+)(-[^/]*)?(\.md)?", partes[-1])
86
+ id_ = _normalizar_id(m.group(1)) if m else ""
87
+ return id_ if id_.split("-")[0] in TIPOS else None
88
+
89
+
90
+ def _resolver_id(proyecto: str, ruta: str) -> Path | None:
91
+ """Archivo real del registro al que se refiere `ruta` (que no existe), o None si `ruta` no tiene forma de id.
92
+ Solo lee, y solo dentro de la carpeta de memoria. Id desconocido → ErrorMemoria con el último id de su tipo."""
93
+ id_ = _id_de_ruta(ruta)
94
+ if id_ is None:
95
+ return None
96
+ prefijo = id_.split("-")[0]
97
+ mem = _mem(proyecto)
98
+ carpeta = mem / TIPOS[prefijo][0]
99
+ try:
100
+ return _buscar_registro(carpeta, id_)
101
+ except ErrorMemoria:
102
+ existentes = _registros_de(mem, prefijo)
103
+ if not existentes:
104
+ raise ErrorMemoria(f"{id_} does not exist; there are no records in {MEM}/{TIPOS[prefijo][0]} yet.")
105
+ raise ErrorMemoria(f"{id_} does not exist; the latest is {existentes[-1].id}.")
106
+
107
+
108
+ def _es_carpeta_de_registros(carpeta: str) -> str | None:
109
+ """Prefijo si `carpeta` es .project/decisions|pending|tasks (con o sin "/" final)."""
110
+ partes = Path(carpeta.strip()).parts
111
+ return CARPETAS.get(partes[1]) if len(partes) == 2 and partes[0] == MEM else None
112
+
113
+
114
+ def _registros_en(proyecto: str, prefijo: str) -> list[Registro]:
115
+ return _registros_de(_mem(proyecto), prefijo)
116
+
117
+
118
+ TOPE_TITULOS = 5 # coincidencias por título que buscar muestra (el id exacto no cuenta)
119
+
120
+
121
+ def _plano(texto: str) -> str:
122
+ """Sin acentos ni mayúsculas, para comparar títulos: "Navegación" → "navegacion"."""
123
+ return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)).casefold()
124
+
125
+
126
+ def _coincidencias(proyecto: str, texto: str) -> tuple[list[Registro], int]:
127
+ """Registros para buscar: primero el cuyo id es `texto` (en cualquier forma que acepte _normalizar_id), luego hasta
128
+ TOPE_TITULOS cuyo título contiene `texto` al inicio de una palabra (sin distinguir mayúsculas ni acentos: "id" no
129
+ coincide con "híbrido"; "asiento" sí con "Asientos"). Devuelve también cuántos títulos quedaron fuera del tope."""
130
+ buscado = _plano(texto.strip())
131
+ if not buscado:
132
+ return [], 0
133
+ objetivo = _normalizar_id(texto)
134
+ patron = re.compile(rf"(?<!\w){re.escape(buscado)}")
135
+ filas = _catalogo(proyecto)
136
+ exactos = [r for r in filas if r.id == objetivo]
137
+ titulos = [r for r in filas if r.id != objetivo and patron.search(_plano(r.titulo))]
138
+ return exactos + titulos[:TOPE_TITULOS], max(len(titulos) - TOPE_TITULOS, 0)
ratifact/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ """Comando `ratifact` (ADR-032): `serve` (servidor MCP por stdio, lo que lanzan los clientes), `install` (configura
2
+ los clientes MCP y /run-task, T-013), `report` (resumen del uso, antes analizar_uso.py), `doctor` (diagnóstico de solo
3
+ lectura) y `upgrade` (paso explícito de ADR-029). `install` y `upgrade` sin `--apply` solo muestran el plan.
4
+ `ratifact --version` muestra la versión."""
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+
10
+ from ratifact import __version__
11
+
12
+
13
+ def servidor():
14
+ """Carga los módulos de herramientas y prompts (sus decoradores los registran en `app.mcp`) y devuelve `mcp`."""
15
+ from ratifact import app, prompts # noqa: F401
16
+ from ratifact import herramientas # noqa: F401
17
+ return app.mcp
18
+
19
+
20
+ def _parser() -> argparse.ArgumentParser:
21
+ from ratifact.informe import argumentos
22
+ ap = argparse.ArgumentParser(prog="ratifact", description="Project memory MCP server (ratifact).")
23
+ ap.add_argument("--version", action="version", version=f"ratifact {__version__}")
24
+ sub = ap.add_subparsers(dest="comando", metavar="{serve,install,report,doctor,upgrade}")
25
+ sub.required = True
26
+ sub.add_parser("serve", help="run the MCP server over stdio (what the clients launch)")
27
+ from ratifact.clientes import NOMBRES
28
+ ins = sub.add_parser("install", help="configure the MCP clients found (Claude Desktop, Claude Code, Pi, Codex) "
29
+ "and link /run-task; shows the plan unless --apply")
30
+ modo_ins = ins.add_mutually_exclusive_group()
31
+ modo_ins.add_argument("--apply", action="store_true", help="execute the plan (with backups)")
32
+ modo_ins.add_argument("--dry-run", action="store_true", help="only show the plan (the default)")
33
+ ins.add_argument("--client", choices=list(NOMBRES), help="configure only this client")
34
+ argumentos(sub.add_parser("report", help="usage summary from the telemetry log"))
35
+ sub.add_parser("doctor", help="read-only diagnosis of the installation; exit 1 if there are pending actions")
36
+ up = sub.add_parser("upgrade", help="move memories to .ratifact/ and projects.json to English keys; "
37
+ "shows the plan unless --apply")
38
+ modo = up.add_mutually_exclusive_group()
39
+ modo.add_argument("--apply", action="store_true", help="execute the plan (close every client first)")
40
+ modo.add_argument("--dry-run", action="store_true", help="only show the plan (the default)")
41
+ return ap
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> None:
45
+ a = _parser().parse_args(argv)
46
+ if a.comando == "serve":
47
+ import atexit
48
+ from ratifact import build, procesos
49
+ vigilante = build.iniciar() # ADR-037: build id and fingerprint of the code this process loads
50
+ atexit.register(procesos.desregistrar, procesos.registrar(vigilante)) # ADR-038: for doctor
51
+ servidor().run()
52
+ elif a.comando == "report":
53
+ from ratifact import informe
54
+ informe.main(a=a)
55
+ elif a.comando == "install":
56
+ from ratifact import instalar
57
+ sys.exit(instalar.main(aplicar=a.apply, cliente=a.client))
58
+ elif a.comando == "doctor":
59
+ from ratifact import doctor
60
+ sys.exit(doctor.main())
61
+ else:
62
+ from ratifact import upgrade
63
+ sys.exit(upgrade.main(aplicar=a.apply))
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
ratifact/clientes.py ADDED
@@ -0,0 +1,206 @@
1
+ """MCP clients that `ratifact install` configures and `ratifact doctor` reports (T-013): Claude Desktop, Claude Code
2
+ (`claude mcp add --scope user` if the `claude` executable exists, otherwise ~/.claude.json), Pi and Codex. Each ends with
3
+ the server key `ratifact`, the absolute path of the installed executable, args ["serve"] and no environment variables;
4
+ an old `memoria-proyectos` entry is replaced (never both). Planning only reads; `aplicar` writes through
5
+ ratifact.archivos (validation, backup, atomic replace)."""
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from ratifact import clientes_toml
17
+ from ratifact.archivos import ErrorInstalacion, escribir_validado, leer_json, respaldar, texto_json
18
+
19
+ CLAVE, CLAVE_VIEJA = clientes_toml.CLAVE, clientes_toml.CLAVE_VIEJA
20
+ CONFIGURADO, VIEJA = "configured with ratifact", "old configuration"
21
+ SIN_CONFIGURAR, NO_ENCONTRADO = "not configured", "not found"
22
+ NOMBRES = {"claude-desktop": "Claude Desktop", "claude-code": "Claude Code", "pi": "Pi", "codex": "Codex"}
23
+ _BUSCAR = object()
24
+
25
+ __all__ = ["ErrorInstalacion", "clientes", "planificar", "aplicar"]
26
+
27
+
28
+ @dataclass
29
+ class Cliente:
30
+ nombre: str
31
+ archivo: Path
32
+ encontrado: bool
33
+ formato: str = "json" # json | toml
34
+ cli: str | None = None # Claude Code: the `claude` executable, if it exists
35
+
36
+ @property
37
+ def titulo(self) -> str:
38
+ return NOMBRES[self.nombre]
39
+
40
+
41
+ def _carpeta_desktop(home: Path) -> Path:
42
+ if sys.platform == "darwin":
43
+ return home / "Library" / "Application Support" / "Claude"
44
+ return home / ".config" / "Claude"
45
+
46
+
47
+ def clientes(claude=_BUSCAR) -> list[Cliente]:
48
+ """The four clients, with whether they are installed on this machine (read at every call, from HOME)."""
49
+ home = Path.home()
50
+ claude = shutil.which("claude") if claude is _BUSCAR else claude
51
+ desktop = _carpeta_desktop(home)
52
+ return [
53
+ Cliente("claude-desktop", desktop / "claude_desktop_config.json", desktop.is_dir()),
54
+ Cliente("claude-code", home / ".claude.json",
55
+ bool(claude) or (home / ".claude.json").exists() or (home / ".claude").is_dir(), cli=claude),
56
+ Cliente("pi", home / ".pi" / "agent" / "mcp.json", (home / ".pi" / "agent").is_dir()),
57
+ Cliente("codex", home / ".codex" / "config.toml", (home / ".codex").is_dir(), formato="toml"),
58
+ ]
59
+
60
+
61
+ @dataclass
62
+ class PlanCliente:
63
+ cliente: Cliente
64
+ estado: str
65
+ error: str = ""
66
+ texto: str | None = None # new content of the file (edit by file)
67
+ comandos: list[list[str]] = field(default_factory=list) # `claude …` calls (edit by CLI)
68
+ descripcion: str = ""
69
+ ejecutable: str = ""
70
+
71
+ @property
72
+ def cambia(self) -> bool:
73
+ return self.texto is not None or bool(self.comandos)
74
+
75
+
76
+ def estado_servidores(servidores, exe: str | None) -> str:
77
+ if not isinstance(servidores, dict):
78
+ return SIN_CONFIGURAR
79
+ if CLAVE_VIEJA in servidores:
80
+ return VIEJA
81
+ e = servidores.get(CLAVE)
82
+ if e is None:
83
+ return SIN_CONFIGURAR
84
+ ok = isinstance(e, dict) and e.get("args") == ["serve"] and not e.get("env") and (
85
+ e.get("command") == exe if exe else Path(str(e.get("command"))).name == "ratifact")
86
+ return CONFIGURADO if ok else VIEJA
87
+
88
+
89
+ def _servidores(c: Cliente, datos: dict):
90
+ return datos.get("mcp_servers" if c.formato == "toml" else "mcpServers")
91
+
92
+
93
+ def leer(c: Cliente) -> tuple[dict, str]:
94
+ """(parsed data, original text). ErrorInstalacion if the file cannot be parsed."""
95
+ if c.formato == "toml":
96
+ texto = c.archivo.read_text(encoding="utf-8") if c.archivo.exists() else ""
97
+ return clientes_toml.parsear(texto, str(c.archivo)), texto
98
+ return leer_json(c.archivo), c.archivo.read_text(encoding="utf-8") if c.archivo.exists() else ""
99
+
100
+
101
+ def estado(c: Cliente, exe: str | None = None) -> str:
102
+ """configured with ratifact / old configuration / not configured / not found / error: … (doctor)."""
103
+ if not c.encontrado:
104
+ return NO_ENCONTRADO
105
+ try:
106
+ datos, _ = leer(c)
107
+ except ErrorInstalacion as e:
108
+ return f"error: {e}"
109
+ return estado_servidores(_servidores(c, datos), exe)
110
+
111
+
112
+ def _entrada_json(c: Cliente, servidores: dict, exe: str) -> dict:
113
+ fuente = servidores.get(CLAVE) if CLAVE in servidores else servidores.get(CLAVE_VIEJA) or {}
114
+ extra = {k: v for k, v in (fuente if isinstance(fuente, dict) else {}).items()
115
+ if k not in ("type", "command", "args", "env")}
116
+ return {**({"type": "stdio"} if c.nombre == "claude-code" else {}), "command": exe, "args": ["serve"], **extra}
117
+
118
+
119
+ def _reemplazar_json(c: Cliente, datos: dict, exe: str) -> dict:
120
+ """Same data with `ratifact` where the first of ratifact / memoria-proyectos was (or at the end)."""
121
+ servidores = datos.get("mcpServers") or {}
122
+ entrada, nuevos = _entrada_json(c, servidores, exe), {}
123
+ for k, v in servidores.items():
124
+ if k in (CLAVE, CLAVE_VIEJA):
125
+ nuevos.setdefault(CLAVE, entrada)
126
+ else:
127
+ nuevos[k] = v
128
+ nuevos.setdefault(CLAVE, entrada)
129
+ salida = dict(datos)
130
+ salida["mcpServers"] = nuevos
131
+ return salida
132
+
133
+
134
+ def _descripcion(estado_actual: str, servidores) -> str:
135
+ if estado_actual == SIN_CONFIGURAR:
136
+ return f"add `{CLAVE}`"
137
+ if isinstance(servidores, dict) and CLAVE_VIEJA in servidores:
138
+ return f"replace `{CLAVE_VIEJA}` with `{CLAVE}`"
139
+ return f"update `{CLAVE}` (command, args [\"serve\"], no env)"
140
+
141
+
142
+ def planificar(c: Cliente, exe: str) -> PlanCliente:
143
+ """What `aplicar` would do for this client. Never writes."""
144
+ if not c.encontrado:
145
+ return PlanCliente(c, NO_ENCONTRADO, ejecutable=exe)
146
+ try:
147
+ datos, texto = leer(c)
148
+ servidores = _servidores(c, datos)
149
+ if servidores is not None and not isinstance(servidores, dict):
150
+ raise ErrorInstalacion(f"{c.archivo}: the servers entry is not a table/object; nothing was changed.")
151
+ actual = estado_servidores(servidores, exe)
152
+ if actual == CONFIGURADO:
153
+ return PlanCliente(c, actual, ejecutable=exe)
154
+ plan = PlanCliente(c, actual, descripcion=_descripcion(actual, servidores), ejecutable=exe)
155
+ if c.formato == "toml":
156
+ plan.texto = clientes_toml.editar(texto, exe)
157
+ elif c.cli:
158
+ servidores = servidores or {}
159
+ plan.comandos = [["mcp", "remove", k, "--scope", "user"] for k in (CLAVE_VIEJA, CLAVE) if k in servidores]
160
+ plan.comandos.append(["mcp", "add", "--scope", "user", CLAVE, "--", exe, "serve"])
161
+ plan.descripcion += " via `claude mcp add --scope user`"
162
+ else:
163
+ plan.texto = texto_json(_reemplazar_json(c, datos, exe))
164
+ return plan
165
+ except ErrorInstalacion as e:
166
+ return PlanCliente(c, f"error: {e}", error=str(e), ejecutable=exe)
167
+ except OSError as e:
168
+ return PlanCliente(c, f"error: {e}", error=f"cannot read {c.archivo}: {e}", ejecutable=exe)
169
+
170
+
171
+ def _validador(c: Cliente, exe: str) -> Callable[[str], None]:
172
+ def validar(texto: str) -> None:
173
+ if c.formato == "toml":
174
+ datos = clientes_toml.parsear(texto, f"the new {c.archivo.name}")
175
+ else:
176
+ try:
177
+ datos = json.loads(texto)
178
+ except ValueError as e:
179
+ raise ErrorInstalacion(f"the new {c.archivo.name} does not parse ({e}); nothing was changed.")
180
+ if estado_servidores(_servidores(c, datos), exe) != CONFIGURADO:
181
+ raise ErrorInstalacion(f"the new {c.archivo.name} does not have the expected `{CLAVE}` entry; "
182
+ "nothing was changed.")
183
+ return validar
184
+
185
+
186
+ def aplicar(plan: PlanCliente, correr: Callable = subprocess.run) -> str:
187
+ """Executes the plan of one client; returns the summary line. ErrorInstalacion if it cannot."""
188
+ c = plan.cliente
189
+ if plan.error:
190
+ raise ErrorInstalacion(plan.error)
191
+ if not plan.cambia:
192
+ return f"{c.titulo}: {plan.estado}"
193
+ if plan.texto is not None:
194
+ bak = escribir_validado(c.archivo, plan.texto, _validador(c, plan.ejecutable))
195
+ return f"{c.titulo}: {plan.descripcion} in {c.archivo}" + (f" (backup: {bak})" if bak else "")
196
+ bak = respaldar(c.archivo)
197
+ for args in plan.comandos:
198
+ r = correr([c.cli, *args], capture_output=True, text=True)
199
+ if r.returncode != 0:
200
+ raise ErrorInstalacion(f"`claude {' '.join(args)}` failed: {(r.stderr or r.stdout).strip()[:300]}"
201
+ + (f" (backup of {c.archivo}: {bak})" if bak else ""))
202
+ final = estado(c, plan.ejecutable)
203
+ if final != CONFIGURADO:
204
+ raise ErrorInstalacion(f"after `claude mcp add`, {c.archivo} is in state '{final}'"
205
+ + (f" (backup: {bak})" if bak else ""))
206
+ return f"{c.titulo}: {plan.descripcion}" + (f" (backup: {bak})" if bak else "")
@@ -0,0 +1,210 @@
1
+ """Bounded edit of ~/.codex/config.toml (T-013): only the `[mcp_servers.memoria-proyectos]` / `[mcp_servers.ratifact]`
2
+ tables and their sub-tables are rewritten; every other line of the file stays byte for byte. The result is accepted only
3
+ if parsing it gives exactly the original data with that one server replaced (tomllib, or tomli on Python 3.10); any
4
+ other layout (inline tables, dotted keys, multi-line strings) is refused with manual instructions."""
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import json
9
+ import re
10
+
11
+ from ratifact.archivos import ErrorInstalacion
12
+ from ratifact.vocabulario import HERRAMIENTAS_V0
13
+
14
+ CLAVE, CLAVE_VIEJA = "ratifact", "memoria-proyectos"
15
+ _QUITAR = ("command", "args", "env")
16
+ _BARE = re.compile(r"[A-Za-z0-9_-]+")
17
+ _PARTE = re.compile(r"""\s*(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*""")
18
+ _ASIGNACION = re.compile(r"""^\s*["']?(command|args|env)["']?\s*[=.]""")
19
+
20
+
21
+ def _parser():
22
+ try:
23
+ import tomllib
24
+ return tomllib
25
+ except ModuleNotFoundError:
26
+ try:
27
+ import tomli
28
+ return tomli
29
+ except ModuleNotFoundError:
30
+ return None
31
+
32
+
33
+ def parsear(texto: str, origen: str = "the file") -> dict:
34
+ toml = _parser()
35
+ if toml is None:
36
+ raise ErrorInstalacion("cannot validate TOML on this Python (install `tomli`, a dependency of ratifact on "
37
+ "Python 3.10); nothing was changed.")
38
+ try:
39
+ return toml.loads(texto)
40
+ except toml.TOMLDecodeError as e:
41
+ raise ErrorInstalacion(f"{origen} is not valid TOML ({e}); fix it by hand, nothing was changed.") from None
42
+
43
+
44
+ def manual(exe: str) -> str:
45
+ return (f"replace the `[mcp_servers.{CLAVE_VIEJA}]` server by hand with:\n"
46
+ f" [mcp_servers.{CLAVE}]\n command = {json.dumps(exe)}\n args = [\"serve\"]\n"
47
+ f" (delete its `.env` table; rename `[mcp_servers.{CLAVE_VIEJA}.tools.<tool>]` approval tables to "
48
+ f"`[mcp_servers.{CLAVE}.tools.<tool>]`)")
49
+
50
+
51
+ def _clave(texto: str) -> list[str] | None:
52
+ """Dotted key of a table header (`a."b.c".d` → [a, b.c, d]); None if it cannot be read."""
53
+ partes, i = [], 0
54
+ while True:
55
+ m = _PARTE.match(texto, i)
56
+ if not m:
57
+ return None
58
+ bare, basica, literal = m.groups()
59
+ partes.append(bare if bare is not None else literal if literal is not None else json.loads(f'"{basica}"'))
60
+ i = m.end()
61
+ if i == len(texto):
62
+ return partes
63
+ if texto[i] != ".":
64
+ return None
65
+ i += 1
66
+
67
+
68
+ def _cabecera(linea: str) -> tuple[list[str] | None, bool] | None:
69
+ """(key, is an array of tables) if the line is a table header; None otherwise."""
70
+ s = linea.strip()
71
+ if not s.startswith("["):
72
+ return None
73
+ doble = s.startswith("[[")
74
+ cuerpo, comillas, i = s[2:] if doble else s[1:], None, 0
75
+ while i < len(cuerpo):
76
+ ch = cuerpo[i]
77
+ if comillas:
78
+ if ch == "\\" and comillas == '"':
79
+ i += 1
80
+ elif ch == comillas:
81
+ comillas = None
82
+ elif ch in "\"'":
83
+ comillas = ch
84
+ elif ch == "]":
85
+ return _clave(cuerpo[:i]), doble
86
+ i += 1
87
+ return None, doble
88
+
89
+
90
+ def _formatear(partes: list[str]) -> str:
91
+ return ".".join(p if _BARE.fullmatch(p) else json.dumps(p) for p in partes)
92
+
93
+
94
+ def _secciones(lineas: list[str]) -> list[tuple[int, int, list[str] | None, bool]]:
95
+ """(start, end, key, array) of each table; the lines before the first header have key []."""
96
+ cabeceras = [(i, *c) for i, l in enumerate(lineas) if (c := _cabecera(l)) is not None]
97
+ inicios = [(0, [], False)] + cabeceras
98
+ fines = [i for i, *_ in cabeceras] + [len(lineas)]
99
+ return [(ini, fin, clave, doble) for (ini, clave, doble), fin in zip(inicios, fines)]
100
+
101
+
102
+ def _profundidad(linea: str) -> int:
103
+ """Brackets opened minus closed outside strings and comments."""
104
+ n, comillas, i = 0, None, 0
105
+ while i < len(linea):
106
+ ch = linea[i]
107
+ if comillas:
108
+ if ch == "\\" and comillas == '"':
109
+ i += 1
110
+ elif ch == comillas:
111
+ comillas = None
112
+ elif ch in "\"'":
113
+ comillas = ch
114
+ elif ch == "#":
115
+ break
116
+ elif ch in "[{":
117
+ n += 1
118
+ elif ch in "]}":
119
+ n -= 1
120
+ i += 1
121
+ return n
122
+
123
+
124
+ def _sin_asignaciones(cuerpo: list[str]) -> list[str]:
125
+ """The body of the main table without command/args/env (also multi-line arrays)."""
126
+ salida, i = [], 0
127
+ while i < len(cuerpo):
128
+ if _ASIGNACION.match(cuerpo[i]):
129
+ depth = _profundidad(cuerpo[i])
130
+ while depth > 0 and i + 1 < len(cuerpo):
131
+ i += 1
132
+ depth += _profundidad(cuerpo[i])
133
+ else:
134
+ salida.append(cuerpo[i])
135
+ i += 1
136
+ return salida
137
+
138
+
139
+ def _herramienta(partes: list[str]) -> list[str]:
140
+ """`tools.obtener_contexto` (v0 Spanish name) → `tools.get_context`."""
141
+ if len(partes) >= 2 and partes[0] == "tools":
142
+ return ["tools", HERRAMIENTAS_V0.get(partes[1], partes[1]), *partes[2:]]
143
+ return partes
144
+
145
+
146
+ def esperado(datos: dict, exe: str) -> dict:
147
+ """What the parsed file must be after the edit."""
148
+ d = copy.deepcopy(datos)
149
+ servidores = d.setdefault("mcp_servers", {})
150
+ viejo, actual = servidores.pop(CLAVE_VIEJA, None), servidores.pop(CLAVE, None)
151
+ fuente = actual if actual is not None else (viejo or {})
152
+ nuevo = {"command": exe, "args": ["serve"], **{k: v for k, v in fuente.items() if k not in _QUITAR}}
153
+ if isinstance(nuevo.get("tools"), dict):
154
+ herramientas: dict = {}
155
+ for k, v in nuevo["tools"].items():
156
+ herramientas.setdefault(HERRAMIENTAS_V0.get(k, k), v)
157
+ nuevo["tools"] = herramientas
158
+ servidores[CLAVE] = nuevo
159
+ return d
160
+
161
+
162
+ def editar(texto: str, exe: str) -> str:
163
+ """New text of config.toml with the `ratifact` server (and without `memoria-proyectos`). ErrorInstalacion if the
164
+ layout is not the known one or the result would not be exactly the expected data."""
165
+ datos = parsear(texto)
166
+ lineas = texto.splitlines(keepends=True)
167
+ if lineas and not lineas[-1].endswith("\n"):
168
+ lineas[-1] += "\n"
169
+ secciones = _secciones(lineas)
170
+ nuestras = [s for s in secciones if s[2] and s[2][:1] == ["mcp_servers"] and s[2][1:2] in ([CLAVE], [CLAVE_VIEJA])]
171
+ if any(s[2] is None for s in secciones) or any(s[3] for s in nuestras):
172
+ raise ErrorInstalacion("config.toml has a table header this edit does not understand; " + manual(exe))
173
+ if any('"""' in l or "'''" in l for ini, fin, *_ in nuestras for l in lineas[ini:fin]):
174
+ raise ErrorInstalacion("config.toml uses multi-line strings in the server table; " + manual(exe))
175
+ clave_fuente = CLAVE if any(s[2][1] == CLAVE for s in nuestras) else CLAVE_VIEJA
176
+ principal = [f"[mcp_servers.{CLAVE}]\n", f"command = {json.dumps(exe)}\n", 'args = ["serve"]\n']
177
+ subtablas, vistas = [], set()
178
+ for ini, fin, clave, _ in nuestras:
179
+ if clave[1] != clave_fuente:
180
+ continue
181
+ if len(clave) == 2:
182
+ principal += _sin_asignaciones(lineas[ini + 1:fin])
183
+ elif clave[2] != "env":
184
+ nueva = ["mcp_servers", CLAVE, *_herramienta(clave[2:])]
185
+ if tuple(nueva) not in vistas:
186
+ vistas.add(tuple(nueva))
187
+ subtablas += [f"[{_formatear(nueva)}]\n", *lineas[ini + 1:fin]]
188
+ bloque = principal + subtablas
189
+ inicios = {s[0] for s in nuestras}
190
+ salida, puesto = [], False
191
+ for ini, fin, *_ in secciones:
192
+ if ini in inicios:
193
+ if not puesto:
194
+ salida += bloque
195
+ puesto = True
196
+ continue
197
+ salida += lineas[ini:fin]
198
+ if not puesto:
199
+ if salida and salida[-1].strip():
200
+ salida.append("\n")
201
+ salida += bloque
202
+ nuevo = "".join(salida)
203
+ try:
204
+ resultado = parsear(nuevo, "the edited config.toml")
205
+ except ErrorInstalacion:
206
+ resultado = None
207
+ if resultado != esperado(datos, exe):
208
+ raise ErrorInstalacion("the server is also defined outside its own table (inline table or dotted keys); "
209
+ + manual(exe))
210
+ return nuevo