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.
- ratifact/__init__.py +7 -0
- ratifact/app.py +202 -0
- ratifact/archivos.py +98 -0
- ratifact/build.py +227 -0
- ratifact/busqueda.py +134 -0
- ratifact/catalogo.py +138 -0
- ratifact/cli.py +67 -0
- ratifact/clientes.py +206 -0
- ratifact/clientes_toml.py +210 -0
- ratifact/config.py +264 -0
- ratifact/delegacion.py +175 -0
- ratifact/doctor.py +299 -0
- ratifact/esquema.py +204 -0
- ratifact/herramientas/__init__.py +3 -0
- ratifact/herramientas/contexto_documentos.py +280 -0
- ratifact/herramientas/decisiones.py +261 -0
- ratifact/herramientas/pendientes_sesiones.py +78 -0
- ratifact/herramientas/proyectos.py +300 -0
- ratifact/herramientas/tareas.py +286 -0
- ratifact/herramientas/tareas_brief.py +184 -0
- ratifact/informe.py +182 -0
- ratifact/informe_memoria.py +279 -0
- ratifact/instalacion.py +247 -0
- ratifact/instalar.py +262 -0
- ratifact/instalar_extras.py +193 -0
- ratifact/integraciones/__init__.py +3 -0
- ratifact/integraciones/base.py +24 -0
- ratifact/integraciones/codegraph.py +19 -0
- ratifact/integraciones/engram.py +14 -0
- ratifact/integraciones/gentle_ai.py +23 -0
- ratifact/integraciones/registro.py +108 -0
- ratifact/listado.py +85 -0
- ratifact/migracion_v2.py +206 -0
- ratifact/modelos.py +131 -0
- ratifact/plantillas.py +51 -0
- ratifact/procesos.py +168 -0
- ratifact/prompts.py +29 -0
- ratifact/registros.py +258 -0
- ratifact/retirado.py +88 -0
- ratifact/rutas.py +73 -0
- ratifact/skills/run-task/SKILL.md +56 -0
- ratifact/upgrade.py +195 -0
- ratifact/upgrade_aplicar.py +185 -0
- ratifact/vocabulario.py +82 -0
- ratifact/workers.py +197 -0
- ratifact-1.0.0.dist-info/METADATA +284 -0
- ratifact-1.0.0.dist-info/RECORD +50 -0
- ratifact-1.0.0.dist-info/WHEEL +4 -0
- ratifact-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|