alegra-data-agent 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,445 +1,21 @@
1
- # Data Agent Self Service
1
+ # alegra-data-agent
2
2
 
3
- > **Cómo se llama cada cosa.** El producto es **Data Agent Self Service** — así aparece en el
4
- > gateway y en el login. El repositorio es `Alegra-Team/data-agent-selfservice` y el paquete npm
5
- > es `alegra-data-agent`: son tres registros independientes y no tienen por qué coincidir.
6
- >
7
- > El paquete **no se renombra**: su nombre está incrustado en el hook de arranque de cada máquina
8
- > ya instalada, así que cambiarlo obligaría a migrar a todo el mundo a mano. Lo mismo vale para
9
- > el `ISSUER`/`AUDIENCE` de los JWT (`gateway/lib/jwt.ts`) — tocarlos invalidaría los tokens ya
10
- > emitidos.
11
-
12
- Agente de Data interno de Alegra. Una línea instala las skills + MCPs del equipo BI Data en tu herramienta de IA (Claude Code, Cursor, OpenCode, Antigravity, Codex, Hermes), gated por OAuth `@alegra.com`.
13
-
14
- > Uso interno de Alegra. No publicar, no promocionar, no contribuciones externas.
15
-
16
- ## Estado
17
-
18
- - **CLI**: [`alegra-data-agent`](https://www.npmjs.com/package/alegra-data-agent) en npmjs.com.
19
- - **Gateway en producción**: [https://alegra-data-agent.vercel.app](https://alegra-data-agent.vercel.app).
20
- - **Healthcheck**: `https://alegra-data-agent.vercel.app/api/health`.
21
- - **Guía de uso y documentación**: [wiki del agente](https://wiki.alegra.com/doc/alegra-data-agent-DKiPc9cvFS) — empezar, cómo preguntar, qué responde, problemas comunes, [arquitectura y seguridad](https://wiki.alegra.com/doc/arquitectura-y-seguridad-vWfNc90Myy) e [historial de cambios](https://wiki.alegra.com/doc/historial-de-cambios-J8uMHq1sfd). La página de entrada del gateway enlaza a todas.
22
-
23
- ## Compatibilidad
24
-
25
- | Sistema operativo | ✓ |
26
- |---|---|
27
- | macOS (Intel + Apple Silicon) | ✅ |
28
- | Ubuntu / Debian / Linux | ✅ |
29
- | WSL (Windows Subsystem for Linux) | ✅ |
30
- | Windows 10/11 nativo | ✅ |
31
-
32
- | Cliente AI | ✓ |
33
- |---|---|
34
- | [Claude Code](https://claude.com/claude-code) | ✅ |
35
- | [Cursor](https://cursor.com) | ✅ |
36
- | [OpenCode](https://opencode.ai) | ✅ |
37
- | [Antigravity](https://antigravity.dev) | ✅ |
38
- | [Codex](https://developers.openai.com/codex) | ✅ |
39
- | [Hermes Agent](https://hermes-agent.nousresearch.com) | ✅ |
40
-
41
- Si no tenés ningún cliente instalado, el bootstrap te instala Claude Code automáticamente.
42
-
43
- ### Si no usás terminal
44
-
45
- El comando de arriba es para clientes CLI/IDE. En **claude.ai, Claude Desktop, Cowork y móvil**
46
- el agente llega sin instalar nada:
47
-
48
- | | Cómo |
49
- |---|---|
50
- | MCPs | Configuración → **Conectores**. En Team/Enterprise el owner los agrega en *Organization settings* y le quedan disponibles a toda Alegra. |
51
- | Skills | Configuración → **Habilidades** → subir el `.zip`. El owner puede provisionarlas para toda la organización y **aparecen solas**, sin que nadie suba nada. |
52
-
53
- Los ZIP se generan con `pnpm run pack:web` (uno por skill, en `dist/skills-web/`).
54
-
55
- Dos cosas que conviene saber:
56
-
57
- - Un conector de la cuenta se sirve a **todas** las superficies, **Claude Code incluido** — ahí
58
- aparecen en `/mcp` bajo `claude.ai`. Si ya tenés el MCP como server local, el conector queda
59
- oculto por URL duplicada; el propio `/mcp` te dice cómo cambiar de uno al otro.
60
- - Los ZIP subidos **no se auto-actualizan**, y no hay API para subirlos. El CLI sí (manifest
61
- firmado). Después de un release, `pnpm run pack:web` dice cuáles cambiaron: solo esos se
62
- vuelven a subir (paso 5 del «Flujo de release»).
63
-
64
- Detalle y el porqué en [ADR-0010](./docs/adr/0010-superficies-no-code.md).
3
+ Agente de Data interno de Alegra. Instala skills + MCPs en tu cliente de IA con un comando.
65
4
 
66
5
  ## Instalación
67
6
 
68
- **El comando único es:**
69
-
70
7
  ```sh
71
8
  pnpm dlx alegra-data-agent
72
9
  ```
73
10
 
74
- Funciona en cualquier terminal (macOS Terminal, GNOME Terminal, WSL, PowerShell) y desde la terminal embebida de cualquier cliente AI (Claude Code, Cursor, OpenCode, Antigravity, Codex, Hermes).
75
-
76
- ### Si no tenés Node o pnpm
77
-
78
- Si al correr `pnpm dlx alegra-data-agent` te dice `pnpm: command not found`, primero corré el bootstrap que te instala Node 22+ y pnpm 11+ automáticamente:
79
-
80
- **macOS / Linux / WSL** — en cualquier terminal:
81
-
82
- ```sh
83
- curl -fsSL https://alegra-data-agent.vercel.app/install.sh | bash
84
- ```
85
-
86
- **Windows nativo** — en PowerShell (sin admin):
87
-
88
- ```powershell
89
- irm https://alegra-data-agent.vercel.app/install.ps1 | iex
90
- ```
91
-
92
- El bootstrap deja Node + pnpm + git + Claude Code listos, y al final corre `pnpm dlx alegra-data-agent` por vos. Después de eso, el comando único es siempre `pnpm dlx alegra-data-agent`.
93
-
94
- ### Después del install
95
-
96
- 1. **Reiniciá tu cliente AI** (Claude Code / Cursor / OpenCode / Antigravity / Codex / Hermes).
97
- 2. **Hacé tu primera pregunta** en español. Ejemplo: *"cuántos nuevos clientes en mayo en Colombia"*.
98
- 3. **La primera vez** que el agente use un servicio (Metabase, Amplitude, Wiki) te va a pedir 1 click con tu cuenta `@alegra.com`. Después no te pide nada más.
99
-
100
- > El agente nunca te va a pedir API keys, tokens, ni variables de entorno. Todo es OAuth `@alegra.com` automático.
101
-
102
- > **HubSpot es la excepción: no lo instala el CLI, se habilita como conector de la cuenta.** El
103
- > servidor de HubSpot no soporta el registro automático que usan los clientes AI, así que una entry
104
- > local nunca autentica. El conector oficial sí funciona: Configuración → Conectores → HubSpot →
105
- > Conectar, y queda disponible en Claude Code, Claude Desktop y claude.ai. En Cursor, OpenCode y
106
- > Codex hoy no hay ruta. Detalle en [`agent/mcps/hubspot/README.md`](./agent/mcps/hubspot/README.md).
11
+ Solo accesible para cuentas `@alegra.com` (OAuth Google). El CLI te abre el navegador para iniciar sesión.
107
12
 
108
13
  ## Requisitos
109
14
 
110
- - Cuenta Google `@alegra.com` verificada (lo único que sí tenés que tener antes).
111
- - Node ≥ 22.6.0 — instala el bootstrap si te falta.
112
- - pnpm ≥ 11.1.2 — instala el bootstrap si te falta.
113
- - Un cliente AI compatible — el bootstrap instala Claude Code si no detecta ninguno.
114
-
115
- ## Qué se instala
116
-
117
- | Herramienta | Skills | Configuración MCP |
118
- |---|---|---|
119
- | Claude Code | `~/.claude/skills/<n>/` (carpeta) | `~/.claude.json` → `mcpServers` |
120
- | Cursor | `~/.cursor/skills/<n>/` (carpeta) | `~/.cursor/mcp.json` → `mcpServers` |
121
- | OpenCode | `~/.config/opencode/skills/<n>/` (carpeta) | `~/.config/opencode/opencode.json` → `mcp` |
122
- | Antigravity | `~/.gemini/skills/<n>/` (carpeta) | `~/.gemini/config/mcp_config.json` → `mcpServers` |
123
- | Codex | `~/.agents/skills/<n>/` (carpeta) | `~/.codex/config.toml` → `[mcp_servers.<n>]` |
124
-
125
- ### Acotar los MCPs a una carpeta (opcional)
126
-
127
- Por defecto los MCPs se instalan a nivel usuario: quedan disponibles en toda sesión, de
128
- cualquier carpeta. Si en la misma máquina separás contextos —trabajo de Alegra, proyectos
129
- propios, trabajo de un cliente— podés acotarlos a una sola carpeta:
130
-
131
- ```bash
132
- mkdir -p ~/.alegra
133
- echo 'MCP_DIR=~/trabajo/alegra' >> ~/.alegra/agent.conf
134
- pnpm dlx alegra-data-agent install --yes
135
- ```
136
-
137
- Los MCPs pasan a `~/trabajo/alegra/.mcp.json`, que Claude Code resuelve subiendo desde el
138
- directorio actual: **cascadea a todo lo que esté dentro de esa carpeta y es invisible fuera
139
- de ella**. Se pre-aprueban solos, así que no hay que aceptarlos repo por repo.
140
-
141
- También funciona como env var (`ALEGRA_DATA_AGENT_MCP_DIR`, le gana al archivo), pero el
142
- archivo es lo recomendado: el hook de auto-update corre en un shell no interactivo y no ve
143
- lo que exportes en tu `.bashrc`, así que reinstalaría a nivel usuario.
144
-
145
- **Qué clientes lo respetan.** Claude Code y Cursor pueden acotar un MCP a una carpeta, así
146
- que con `MCP_DIR` los MCPs van solo a esos dos. OpenCode, Hermes, Codex y Antigravity no
147
- pueden: OpenCode busca su config de proyecto solo hasta la raíz del repo git, y Hermes guarda
148
- los MCPs por perfil. Ahí los MCPs quedarían globales, así que no se instalan y se retiran los
149
- que ya tuvieran. Las skills sí se instalan en todos. Para consultar datos de Alegra, usá
150
- Claude Code o Cursor dentro de la carpeta.
151
-
152
- **Se corrige solo.** Cada arranque compara dónde debería estar cada MCP con dónde está y
153
- retira lo que sobra, siempre que sea nuestro: una copia en `~/.claude.json` de antes de
154
- configurar `MCP_DIR`, la de una carpeta vieja si lo cambiaste o lo sacaste, o la de un cliente
155
- que no corresponde. Un server tuyo con el mismo nombre y otra configuración no se toca.
156
- `alegra-data-agent status` te dice en qué archivo quedaron y avisa si ve algo fuera de lugar.
157
-
158
- Detalle completo en [ADR-0012](docs/adr/0012-mcp-scope-por-workspace.md).
159
-
160
- ### Dónde quedan los archivos que genera el agente
161
-
162
- Cuando pedís un CSV, un Excel o un gráfico, el agente lo guarda en `alegra-data/` dentro de
163
- la carpeta donde abriste la sesión, y te dice la ruta al terminar (en WSL, también la de
164
- Windows). Nunca en los temporales del cliente. Para que vaya siempre a la misma carpeta:
165
-
166
- ```bash
167
- mkdir -p ~/.alegra
168
- echo "OUTPUT_DIR=$HOME/Documentos/alegra-data" >> ~/.alegra/agent.conf
169
- ```
170
-
171
- Lo lee el agente, no el CLI, así que aplica desde la siguiente pregunta, sin reinstalar. En
172
- claude.ai, Desktop y Cowork el archivo aparece para descargar en el chat.
173
-
174
- El CLI:
175
-
176
- 1. Verifica auth contra el gateway (OAuth Google `hd=alegra.com`).
177
- 2. Descarga el manifest firmado Ed25519 y verifica firma antes de tocar disco.
178
- 3. Verifica SHA-256 por archivo antes de escribirlo.
179
- 4. Escribe atómicamente (tmp + rename) los configs JSON y respalda lo que ya existía (`*.bak-<ts>`).
180
- 5. Registra en `~/.config/alegra-data-agent/lock.json` qué quedó instalado por target.
181
- 6. Configura **auto-update en cada cliente instalado** (los 5, no solo Claude Code): Claude Code y Codex vía hook `SessionStart`, Cursor vía `workspaceOpen`, Antigravity vía `SessionStart` (Gemini), OpenCode vía plugin. Al abrir el cliente, el contenido se actualiza solo.
182
-
183
- ## Comandos
184
-
185
- ```sh
186
- pnpm dlx alegra-data-agent # instala / actualiza todo (default)
187
- pnpm dlx alegra-data-agent --target=claude-code,cursor
188
- pnpm dlx alegra-data-agent --only=alegra-sales,alegra-growth
189
- pnpm dlx alegra-data-agent --dry-run
190
-
191
- pnpm dlx alegra-data-agent update # re-descarga lo que cambió
192
- pnpm dlx alegra-data-agent status # estado actual + drift
193
- pnpm dlx alegra-data-agent list # catálogo del gateway
194
- pnpm dlx alegra-data-agent uninstall
195
-
196
- pnpm dlx alegra-data-agent login
197
- pnpm dlx alegra-data-agent logout
198
- pnpm dlx alegra-data-agent whoami
199
- ```
200
-
201
- Ayuda completa: `pnpm dlx alegra-data-agent help`.
202
-
203
- > El CLI siempre termina ejecutando la última versión publicada: si pnpm sirve una anterior (cooldown de 24h de pnpm 11), el comando detecta la nueva en el registry y se re-ejecuta solo con ella (self-bounce).
204
-
205
- ## Skills
206
-
207
- 16 skills. Cada equipo dueño mantiene la suya; BI Data mantiene la infraestructura y las skills horizontales.
208
-
209
- | Skill | Dueño | Tipo |
210
- |---|---|---|
211
- | `alegra-bi` (router) | BI Data | Router |
212
- | `alegra-strategic` | Strategic | Dominio |
213
- | `alegra-product` | Producto | Dominio |
214
- | `alegra-sales` | Sales | Dominio |
215
- | `alegra-accountant` | Programa Contadores | Dominio |
216
- | `alegra-cx` | Customer Experience | Dominio |
217
- | `alegra-growth` | Growth | Dominio |
218
- | `alegra-alanube` | Alanube | Dominio |
219
- | `alegra-collection` | Collection | Dominio |
220
- | `alegra-sr` | Strategic Relationships | Dominio |
221
- | `alegra-board` | BI Data | Dominio |
222
- | `dashboard-auditor` | BI Data | Horizontal (review) |
223
- | `dataviz-reviewer` | BI Data | Horizontal (review) |
224
- | `presentation-coach` | BI Data | Horizontal (coach) |
225
- | `launch-measurement` | BI Data | Horizontal (proceso) |
226
- | `agent-onboarding` | BI Data | Soporte |
227
-
228
- Arquitectura: [`docs/skills-architecture.md`](./docs/skills-architecture.md). Contrato de ejecución (MBQL por defecto, SQL nativo de fallback, acceso MCP-first sin tokens): [`agent/skills/SKILL_STANDARD.md`](./agent/skills/SKILL_STANDARD.md) §3.1.
229
-
230
- ### Cómo responde el agente
231
-
232
- Tres reglas transversales que heredan todas las skills de dominio (detalle en [`agent/skills/SKILL_STANDARD.md`](./agent/skills/SKILL_STANDARD.md)):
233
-
234
- - **Pregunta la población, asume los defaults.** Si falta el período, el país o el segmento, elige el más útil y lo avisa en una línea. Si no está claro **qué se cuenta** — "nuevos usuarios" puede ser signups (se registraron) o new logos (empezaron a pagar), que difieren en un orden de magnitud — pregunta antes de consultar. El vocabulario de poblaciones vive en el router (`alegra-bi`, sección «Vocabulario»).
235
- - **La respuesta dice de dónde sale el número.** `New logos (empezaron a pagar · fact_customers_mrr) | 71`, no `Nuevos | 71`. Una cifra sin procedencia no se puede auditar, y las mismas palabras nombran poblaciones distintas según la tabla.
236
- - **Una pregunta = una consulta.** Dos si hace falta una semilla o un delta. MBQL por defecto; cuando MBQL no lo expresa (CTEs, subqueries, window functions) va SQL nativo por `execute_sql` en una llamada, en vez de partirlo en varias.
237
-
238
- **Fuentes de verdad, cuando dos tablas dan números distintos:** `dm_strategic.fact_customers_mrr` manda para todo lo que sea quién paga, quién empezó y quién dejó de pagar. `dm_sales.fact_closed_deals` es su vista comercial (quién cerró, contra qué meta), conectada por `id_company`. Para eventos de uso de producto, `amplitude_events_enriched` en Metabase — no el MCP de Amplitude, no las columnas `*_m` de retención.
239
-
240
- ## MCPs
241
-
242
- Los remotos van vía OAuth — el cliente abre el flow en el primer uso, **cero tokens manuales, cero env vars con secretos**. `salesforce` es la excepción: MCP stdio local del vendor, auth por la Salesforce CLI (`sf org login web --set-default`, requiere Node ≥ 22), solo lectura, para casos puntuales fuera del mirror de Metabase. No se autentica desde `/mcp` — soporte en el Caso 11 de `agent-onboarding`.
243
-
244
- | MCP | Backend | Transporte | Auth |
245
- |---|---|---|---|
246
- | `amplitude` | Amplitude SaaS | remoto (http) | OAuth |
247
- | `hubspot` | HubSpot SaaS | conector de cuenta | OAuth — **no se instala**: se habilita en Configuración → Conectores ([por qué](./agent/mcps/hubspot/README.md)) |
248
- | `metabase` | Metabase self-hosted | remoto (http) | OAuth corporativo |
249
- | `salesforce` | Salesforce DX (solo lectura) | stdio local | Salesforce CLI (`sf org login web`) — no por `/mcp` |
250
- | `wiki-alegra` | Wiki self-hosted | remoto (http) | OAuth corporativo |
251
-
252
- Llave universal para joins: `id_company`. Detalles en [`docs/joining-data-sources.md`](./docs/joining-data-sources.md).
253
-
254
- ## Para mantenedores
255
-
256
- Skills viven en `agent/skills/<name>/SKILL.md` (+ anexos). MCPs en `agent/mcps/<name>/config.example.json`. El manifest firmado se regenera cuando esos paths cambian — **hoy en local**, porque Actions está apagado (ver «Release sin Actions» más abajo).
257
-
258
- ### Flujo de release
259
-
260
- > Los pasos 1 y 2 describen el flujo **con CI**. Mientras Actions siga apagado, la validación y la firma van en local: ver «Release sin Actions» justo abajo.
261
-
262
- 1. PR `<rama>-changes` o `feature/* → testing` modificando `agent/skills/**` o `agent/mcps/**` (CI valida standards, scan y frescura del manifest).
263
- 2. PR `testing → main`: el workflow `sign-manifest.yml` se dispara en el PR (toca esos paths), regenera el manifest, lo escanea, lo firma con `ALEGRA_SIGNING_KEY` y commitea el manifest firmado de vuelta al branch del PR vía la REST API de GitHub (queda Verified, server-side). Al mergear, `main` ya tiene el manifest correcto.
264
- 3. Gateway en Vercel se redespliega manualmente con `pnpm run deploy` desde `gateway/` (corre `prepare:skills`, que verifica firma y sha256, y después `vercel --prod`; nunca `vercel --prod` a secas). La integración Vercel ↔ GitHub no está habilitada: el proyecto de Vercel vive en su propio team y no depende de la organización de GitHub.
265
- 4. Próximo `update` de cualquier alegrito trae los cambios — sin republicar el CLI a npm.
266
- 5. **Superficies no-code (claude.ai, Desktop, Cowork).** Los pasos anteriores no llegan ahí: esos
267
- ZIP se suben a mano y no hay API para automatizarlo (ADR-0010). `pnpm run pack:web` lista
268
- exactamente cuáles cambiaron respecto de lo que ya está en la organización. Se suben esos, y
269
- después `pnpm run pack:web -- --mark-published <skills>` registra lo subido en
270
- `agent/web-published.json`, que se commitea. Sin este paso, quien usa Desktop sigue con las
271
- definiciones viejas sin saberlo.
272
-
273
- ### Release sin Actions
274
-
275
- Con los workflows apagados, los dos pasos que hacía el CI se hacen en local. Requieren la clave
276
- privada de firma (gestor del equipo) y el token de npm.
277
-
278
- ```sh
279
- # 1. Validar, escanear, regenerar y FIRMAR el manifest
280
- ALEGRA_SIGNING_KEY="$(cat /ruta/a/la/clave)" pnpm run manifest
281
-
282
- # 2. Verificar que el manifest quedó firmado y válido antes de commitear
283
- node --experimental-strip-types -e "import('./src/signature.ts').then(async m => {
284
- const man = JSON.parse((await import('node:fs')).readFileSync('agent/skills/index.json','utf-8'));
285
- const r = m.verifyManifestSignature(man);
286
- console.log(r.ok ? 'firma válida — ' + man.signature.keyId : 'INVÁLIDA: ' + r.reason);
287
- })"
288
-
289
- # 3. Publicar a npm: DESPUÉS del merge a main, del deploy del gateway verificado en producción
290
- # con una sesión real (manifest nuevo, firma válida, update instala) y del tag. Lección de la 0.5.0.
291
- pnpm run prepublishOnly && cp .github/README-npm.md README.md && pnpm publish --access public --no-git-checks
292
- git checkout README.md # devolver el README completo al repo
293
- ```
294
-
295
- > `--no-git-checks` es obligatorio acá, no opcional: el swap del paso anterior deja el working tree
296
- > "sucio" a propósito (es exactamente lo que hace `publish.yml` en `Swap to minimal README for npm
297
- > tarball` + `--no-git-checks` en su paso `Publish`). Sin el flag, `pnpm publish` rechaza con
298
- > `ERR_PNPM_GIT_UNCLEAN` — no es una falla real, es el propio swap documentado arriba.
299
-
300
- El manifest firmado se commitea en el PR, en vez de que el bot lo commitee al branch. Todo lo
301
- demás del flujo no cambia.
302
-
303
- **Después de publicar, la wiki.** Copiar la entrada nueva del `CHANGELOG.md` **arriba** de la
304
- página hija [Historial de cambios](https://wiki.alegra.com/doc/historial-de-cambios-J8uMHq1sfd) y
305
- actualizar la línea **Estado** del home de la wiki (versión, skills, fecha). El home no lleva
306
- historial: solo el enlace a esa página, para que no crezca con cada release. El CHANGELOG del repo
307
- es la fuente; la wiki lo copia, nunca al revés.
308
-
309
- ### Publicación de nueva versión del CLI
310
-
311
- 1. Bump `version` en `package.json` y `gateway/package.json` + entrada en `CHANGELOG.md` + regenerar el manifest con **`pnpm run manifest`** (el pipeline completo: `validate` → `scan` → `generate` → `sign`; el `productVersion` sale de package.json). Todo dentro del mismo PR del release.
312
- 2. Tras el merge a main, tag sobre el merge commit: `git tag -s vX.Y.Z && git push origin vX.Y.Z`.
313
- 3. **Con Actions apagado este paso no ocurre:** el tag no publica nada y el publish se hace a mano (ver «Release sin Actions»). Cuando Actions vuelva, el workflow `publish.yml` valida que `package.json` coincida con el tag, corre los gates (validate → scan → tests) y publica: build → swap a `.github/README-npm.md` (README mínimo del tarball) → `pnpm publish --access public`. Requiere el secret `NPM_TOKEN` configurado.
314
-
315
- > ⚠️ **Nunca corras `manifest:generate` solo.** Borra el bloque `signature` de
316
- > `agent/skills/index.json`, y `verifyManifestSignature` es hard-fail: un manifest sin firma hace
317
- > que el CLI rechace **toda** instalación y update, no solo la skill que tocaste. Siempre
318
- > `pnpm run manifest`, que firma al final. `tests/security/manifest-committed.test.mjs` lo caza en
319
- > local si se te pasa.
320
-
321
- **Si el release sale mal:** mover el dist-tag `latest` hacia atrás **no revierte a nadie** — el
322
- hook resuelve por rango semver y los tags no participan. El procedimiento real está en
323
- [`docs/rollback.md`](./docs/rollback.md).
324
-
325
- ### Local
326
-
327
- ```sh
328
- pnpm run manifest:scan # buscar prompt-injection / credenciales
329
- pnpm run manifest:generate # regenera agent/skills/index.json
330
- pnpm run manifest:sign # firma (requiere ALEGRA_SIGNING_KEY)
331
- pnpm run manifest # los cuatro en orden (validate → scan → generate → sign)
332
-
333
- pnpm install
334
- pnpm typecheck
335
- pnpm build # → dist/
336
-
337
- pnpm test # Fase 1: SOLO valida el schema de los evals — no
338
- # ejecuta ningún agente
339
- pnpm test:security # la suite real: firma, paths, clientes, drift de MCPs
340
- pnpm test:all # ambos
341
-
342
- pnpm run eval:run -- --local # Fase 2: ejecuta los evals de verdad contra las skills
343
- # del repo. Cuesta tokens y tarda horas (116 casos); para
344
- # un caso puntual: --only <nombre-del-caso>
345
-
346
- pnpm run verify:clients # E2E: instala en un HOME temporal con 5 de los 6 clientes
347
- # simulados (Hermes aún no está cubierto) y verifica rutas, shapes de MCP, hooks
348
- # e idempotencia (requiere sesión y red)
349
- ```
350
-
351
- > **`test:all` en verde no dice que las skills respondan bien.** La Fase 1 solo lee los YAML;
352
- > si tardó un segundo, no ejecutó nada. Medir routing, tablas y números es `eval:run`: corre cada
353
- > caso aislado (sin el repo ni la memoria del dev a la vista) y consulta Metabase de verdad.
354
- > Detalle en [`docs/evals.md`](./docs/evals.md).
355
-
356
- El grueso de la verificación corre en local, no en CI: la organización está en plan Free y el
357
- pool de minutos de Actions es compartido. `pre-commit install && pre-commit install --hook-type
358
- pre-push` deja los mismos gates enganchados a los commits.
359
-
360
- ### Keypair de firma
361
-
362
- ```sh
363
- pnpm run signing-key:generate # escribe la privada a un archivo 600; NO la imprime
364
- ```
365
-
366
- La pública va en `TRUSTED_KEYS` (`src/signature.ts`); la privada, **en dos lugares**: el gestor
367
- de contraseñas del equipo y el secret `ALEGRA_SIGNING_KEY` del repo. Un secret de GitHub no se
368
- puede leer: si es la única copia, se pierde en cuanto haga falta usarla en otro lado — pasó con
369
- la clave v1 y obligó a rotar.
370
-
371
- El CLI acepta **varias claves a la vez**, que es lo que permite rotar sin ventana de rotura: con
372
- una sola, el CLI nuevo rechaza el manifest viejo *y* el viejo rechaza el nuevo. Procedimiento
373
- completo en [SECURITY.md](SECURITY.md#custodia-y-rotación-de-la-clave-de-firma).
374
-
375
- ## Estructura del repo
376
-
377
- ```
378
- data-agent-selfservice/
379
- ├── src/ # CLI TypeScript (publicado a npm como dist/)
380
- ├── scripts/ # generate-manifest, sign-manifest, scan-skills, generate-signing-key, verify-clients
381
- ├── agent/
382
- │ ├── skills/ # SKILL.md + anexos, agrupados por dueño
383
- │ └── mcps/ # config.example.json por MCP
384
- ├── gateway/ # Next.js en Vercel — OAuth + manifest firmado + skills
385
- ├── docs/ # ADRs + guías
386
- ├── tests/ # evals + security tests
387
- ├── install.sh # bootstrap para macOS/Linux/WSL (servido por gateway)
388
- └── install.ps1 # bootstrap para Windows (servido por gateway)
389
- ```
390
-
391
- ## Troubleshooting
392
-
393
- | Síntoma | Probable causa | Fix |
394
- |---|---|---|
395
- | `pnpm: command not found` | pnpm no instalado | `corepack enable && corepack prepare pnpm@11.1.2 --activate` |
396
- | `Node.js >= 22.6.0` requerido | Node viejo | `nvm install 22` o reinstala desde nodejs.org |
397
- | `Solo cuentas @alegra.com` | OAuth con cuenta personal | Login con tu cuenta corporativa |
398
- | El cliente no ve los MCPs | Cambio de config no recargado | Reinicia el cliente |
399
- | `keyId no reconocido` | El CLI no conoce la clave con la que se firmó el manifest (rotación en curso y CLI viejo) | `pnpm dlx alegra-data-agent@latest`. Durante una rotación el CLI acepta la clave nueva y la anterior, así que esto solo aparece en versiones muy atrasadas |
400
- | Una versión recién publicada no se instala / no actualiza | Cooldown de pnpm 11 (`minimumReleaseAge`, ~24h) sobre versiones de <24h | Desde 0.2.2 el CLI hace self-bounce: aunque pnpm sirva una versión anterior, el comando detecta la última en el registry y se re-ejecuta con ella solo. El self-update (hook) y el bootstrap además corren con `--config.minimumReleaseAge=0`. Para forzarlo a mano: `pnpm dlx alegra-data-agent@<versión>` (pin exacto) |
401
-
402
- Si nada funciona: la skill `agent-onboarding` (instalada por defecto) tiene una guía paso a paso de troubleshooting que el modelo puede correr contigo.
403
-
404
- ## Branching
405
-
406
- `<nombre>-changes → testing → main`. Cada quien trabaja en su rama personal (`jhon-changes`,
407
- `luis-changes`…) y llega por PR: primero a `testing`, después a `main`. Nada entra directo a
408
- `main`, y **el PR a `main` lo aprueba y mergea otra persona** — nadie mergea el suyo. Detalle en
409
- [CONTRIBUTING.md](CONTRIBUTING.md).
410
-
411
- Cambios a `agent/skills/**` o `agent/mcps/**` disparan el workflow de firma en el PR hacia `main`
412
- (el manifest firmado se commitea al branch del PR antes del merge). Cambios a `scripts/**.mjs`
413
- requieren review humano: no autoejecutan la firma, para que un PR a los scripts no pueda
414
- exfiltrar la clave.
415
-
416
- > En plan Free los repos privados no tienen rulesets, así que esto es **un acuerdo del equipo,
417
- > no un candado técnico**. Lo mismo vale para los commits firmados. Ver
418
- > [Cómo trabajamos en GitHub (Alegra-Team)](https://wiki.alegra.com/doc/como-trabajamos-en-github-alegra-team-2WuRXpYEmg).
419
-
420
- ## CI / Workflows
421
-
422
- > ⚠️ **Hoy Actions está deshabilitado en este repo.** La organización agotó su presupuesto
423
- > (`the job was not started because recent account payments have failed or your spending limit
424
- > needs to be increased`), así que ningún workflow puede correr y dejarlos activos solo llenaría
425
- > los PRs de checks rojos que no dicen nada del código. **Mientras dure, todo es local**: la
426
- > validación con pre-commit/pre-push, y la firma del manifest y la publicación a npm a mano —
427
- > ver «Release sin Actions» abajo. Para reactivarlos cuando haya presupuesto:
428
- > `gh api -X PUT /repos/Alegra-Team/data-agent-selfservice/actions/permissions -F enabled=true`.
429
-
430
- Cuatro workflows, y solo uno corre por PR. El pool de minutos de Actions es compartido entre
431
- todos los repos de la organización (plan Free), así que en GitHub queda únicamente lo que no se
432
- puede garantizar en la máquina del autor; el resto vive en `.pre-commit-config.yaml`.
433
-
434
- | Workflow | Cuándo dispara | Qué hace |
435
- |---|---|---|
436
- | `pr.yml` | PRs a `testing` y a `main` (`paths-ignore` docs-only) | **Un solo job**: gates pnpm-only, standards de skills/MCPs (strict), scan de secretos y prompt injection, frescura del manifest, typecheck y tests. El typecheck del gateway solo si el PR toca `gateway/` |
437
- | `sign-manifest.yml` | PRs a `main` que tocan `agent/skills/**` o `agent/mcps/**` | Regenera + escanea + firma el manifest y lo commitea al branch del PR vía REST API de GitHub, no con `git push`: así queda firmado server-side y Verified. Necesita el secret `ALEGRA_SIGNING_KEY` |
438
- | `security.yml` | Schedule semanal + `workflow_dispatch` | Gitleaks + OSV + Trivy + pnpm audit. CodeQL solo bajo dispatch explícito (es el job más caro). No corre por push ni por PR |
439
- | `publish.yml` | Tag `v*.*.*` | Valida versión == tag + gates + publica el CLI a npmjs.com. Necesita el secret `NPM_TOKEN` |
15
+ - Node ≥ 22.6.0
16
+ - pnpm ≥ 11.1.2
17
+ - Uno de: Claude Code, Cursor, OpenCode, Antigravity, Codex, Hermes Agent
440
18
 
441
- Por qué un solo job y no varios: GitHub factura por job con un mínimo de un minuto, así que
442
- partir la validación en cuatro costaba cuatro minutos aunque dos de ellos fueran un `grep` de
443
- tres segundos.
19
+ ## Soporte
444
20
 
445
- Dependabot apunta a `testing` como cualquier otro cambio, no directo a main).
21
+ Guía de uso, documentación y soporte para el equipo de Alegra: entra a https://alegra-data-agent.vercel.app con tu cuenta `@alegra.com`.
@@ -4,7 +4,7 @@ import { logWarn } from "./colors.js";
4
4
  import { hookCommandFor, runHookInstall, runHookUninstall, } from "./commands/hook.js";
5
5
  import { HERMES_PLUGIN_NAME, hermesPluginManifest, hermesPluginModule } from "./hermes-plugin.js";
6
6
  import { runHermesCliAt } from "./installer.js";
7
- import { COMMAND_NAME, getClaudeSettingsPath, getCodexHooksPath, getCursorHooksPath, getGeminiSettingsPath, getHermesHome, getHermesPluginsDir, getOpenCodePluginPath, readLockFile, } from "./lib.js";
7
+ import { COMMAND_NAME, getAntigravityHooksPath, getClaudeSettingsPath, getCodexHooksPath, getCursorHooksPath, getGeminiSettingsPath, getHermesHome, getHermesPluginsDir, getOpenCodePluginPath, readLockFile, } from "./lib.js";
8
8
  function readJsonSafe(path) {
9
9
  if (!existsSync(path))
10
10
  return {};
@@ -60,6 +60,7 @@ export function guiResilientCommand(base, opts) {
60
60
  `else mkdir -p "$(dirname ${logFile})" && ` +
61
61
  `echo "$(date '+%Y-%m-%d %H:%M:%S') [${opts.client}] pnpm no esta en el PATH del cliente; auto-update omitido" >> ${logFile}; fi`);
62
62
  }
63
+ const CURSOR_HOOK_EVENTS = ["workspaceOpen", "sessionStart"];
63
64
  function installCursor(command) {
64
65
  const path = getCursorHooksPath();
65
66
  const cfg = readJsonSafe(path);
@@ -69,9 +70,11 @@ function installCursor(command) {
69
70
  }
70
71
  if (typeof cfg.version !== "number")
71
72
  cfg.version = 1;
72
- const list = freshHookList(cfg, "workspaceOpen");
73
- list.push({ command });
74
- cfg.hooks.workspaceOpen = list;
73
+ for (const event of CURSOR_HOOK_EVENTS) {
74
+ const list = freshHookList(cfg, event);
75
+ list.push({ command, timeout: 120 });
76
+ cfg.hooks[event] = list;
77
+ }
75
78
  writeJson(path, cfg);
76
79
  }
77
80
  function installCodex(command) {
@@ -81,34 +84,74 @@ function installCodex(command) {
81
84
  logWarn(`${path} no es JSON válido; salteo el auto-update de Codex.`);
82
85
  return;
83
86
  }
87
+ const before = JSON.stringify(cfg);
84
88
  const list = freshHookList(cfg, "SessionStart");
85
89
  list.push({ matcher: "startup|resume", hooks: [{ type: "command", command }] });
86
90
  cfg.hooks.SessionStart = list;
91
+ if (JSON.stringify(cfg) === before)
92
+ return;
87
93
  writeJson(path, cfg);
88
94
  }
95
+ const ANTIGRAVITY_HOOK_NAME = "alegra-data-agent-autoupdate";
96
+ export function antigravityHookCommand(resilient, platform = process.platform) {
97
+ if (platform === "win32")
98
+ return resilient;
99
+ return `(${resilient}) </dev/null >/dev/null 2>&1 & echo '{}'`;
100
+ }
89
101
  function installAntigravity(command) {
90
- const path = getGeminiSettingsPath();
102
+ const path = getAntigravityHooksPath();
91
103
  const cfg = readJsonSafe(path);
92
104
  if (cfg === null) {
93
105
  logWarn(`${path} no es JSON válido; salteo el auto-update de Antigravity.`);
94
106
  return;
95
107
  }
96
- const list = freshHookList(cfg, "SessionStart");
97
- list.push({ hooks: [{ type: "command", command }] });
98
- cfg.hooks.SessionStart = list;
108
+ cfg[ANTIGRAVITY_HOOK_NAME] = {
109
+ SessionStart: [{ type: "command", command, timeout: 120 }],
110
+ };
99
111
  writeJson(path, cfg);
112
+ removeJsonHook(getGeminiSettingsPath(), "SessionStart");
113
+ }
114
+ function removeAntigravityHook() {
115
+ const path = getAntigravityHooksPath();
116
+ const cfg = readJsonSafe(path);
117
+ if (cfg && ANTIGRAVITY_HOOK_NAME in cfg) {
118
+ delete cfg[ANTIGRAVITY_HOOK_NAME];
119
+ writeJson(path, cfg);
120
+ }
121
+ removeJsonHook(getGeminiSettingsPath(), "SessionStart");
122
+ }
123
+ function antigravityHookIsOurs() {
124
+ const cfg = readJsonSafe(getAntigravityHooksPath());
125
+ return Boolean(cfg && ANTIGRAVITY_HOOK_NAME in cfg);
100
126
  }
101
127
  function installOpenCode(command) {
102
128
  const path = getOpenCodePluginPath();
103
129
  mkdirSync(dirname(path), { recursive: true });
104
130
  const plugin = `// alegra-data-agent: auto-update al iniciar OpenCode. Generado por el instalador.
105
131
  // Borrar este archivo desactiva el auto-update. No editar a mano.
106
- export const AlegraDataAgentAutoUpdate = async ({ $ }) => {
107
- if (!$) return {}
132
+ import { spawn } from "node:child_process"
133
+
134
+ const COMMAND = ${JSON.stringify(command)}
135
+
136
+ function runUpdate() {
108
137
  try {
109
- $\`${command}\`.quiet().nothrow().catch(() => {})
138
+ const child = spawn(COMMAND, { shell: true, detached: true, stdio: "ignore", windowsHide: true })
139
+ child.on("error", () => {})
140
+ child.unref()
110
141
  } catch (_e) {}
111
- return {}
142
+ }
143
+
144
+ export default {
145
+ id: "alegra-data-agent-autoupdate",
146
+ // OpenCode 1.x
147
+ server: async () => {
148
+ runUpdate()
149
+ return {}
150
+ },
151
+ // OpenCode 2.x
152
+ setup: async () => {
153
+ runUpdate()
154
+ },
112
155
  }
113
156
  `;
114
157
  writeFileSync(path, plugin, "utf-8");
@@ -187,7 +230,10 @@ export function installAutoUpdate(target, mode = "auto") {
187
230
  installCodex(hookCommandFor(effective));
188
231
  break;
189
232
  case "antigravity":
190
- installAntigravity(guiResilientCommand(hookCommandFor(effective), { client: "antigravity", silence: true }));
233
+ installAntigravity(antigravityHookCommand(guiResilientCommand(hookCommandFor(effective), {
234
+ client: "antigravity",
235
+ silence: true,
236
+ })));
191
237
  break;
192
238
  case "opencode":
193
239
  installOpenCode(hookCommandFor(effective));
@@ -208,13 +254,14 @@ export function removeAutoUpdate(target) {
208
254
  runHookUninstall({ quiet: true });
209
255
  break;
210
256
  case "cursor":
211
- removeJsonHook(getCursorHooksPath(), "workspaceOpen");
257
+ for (const event of CURSOR_HOOK_EVENTS)
258
+ removeJsonHook(getCursorHooksPath(), event);
212
259
  break;
213
260
  case "codex":
214
261
  removeJsonHook(getCodexHooksPath(), "SessionStart");
215
262
  break;
216
263
  case "antigravity":
217
- removeJsonHook(getGeminiSettingsPath(), "SessionStart");
264
+ removeAntigravityHook();
218
265
  break;
219
266
  case "opencode": {
220
267
  const p = getOpenCodePluginPath();
@@ -243,11 +290,11 @@ export function hasAutoUpdate(target) {
243
290
  case "claude-code":
244
291
  return jsonHookHasOurs(getClaudeSettingsPath(), "SessionStart");
245
292
  case "cursor":
246
- return jsonHookHasOurs(getCursorHooksPath(), "workspaceOpen");
293
+ return CURSOR_HOOK_EVENTS.some((event) => jsonHookHasOurs(getCursorHooksPath(), event));
247
294
  case "codex":
248
295
  return jsonHookHasOurs(getCodexHooksPath(), "SessionStart");
249
296
  case "antigravity":
250
- return jsonHookHasOurs(getGeminiSettingsPath(), "SessionStart");
297
+ return antigravityHookIsOurs() || jsonHookHasOurs(getGeminiSettingsPath(), "SessionStart");
251
298
  case "opencode":
252
299
  return existsSync(getOpenCodePluginPath());
253
300
  case "hermes": {
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { COMMAND_NAME, getConfigDir } from "./lib.js";
7
+ function consentPath() {
8
+ return join(getConfigDir(), "codex-hook-trust.json");
9
+ }
10
+ export function hasCodexTrustConsent() {
11
+ return existsSync(consentPath());
12
+ }
13
+ export function saveCodexTrustConsent() {
14
+ const path = consentPath();
15
+ mkdirSync(dirname(path), { recursive: true });
16
+ writeFileSync(path, `${JSON.stringify({ consented: true, at: new Date().toISOString() })}\n`);
17
+ }
18
+ function codexBin() {
19
+ const bin = process.env.CODEX_BIN;
20
+ return bin && bin.length > 0 ? bin : "codex";
21
+ }
22
+ async function withAppServer(fn, timeoutMs) {
23
+ const child = spawn(codexBin(), ["app-server"], {
24
+ stdio: ["pipe", "pipe", "ignore"],
25
+ windowsHide: true,
26
+ shell: process.platform === "win32",
27
+ });
28
+ const pending = new Map();
29
+ let nextId = 0;
30
+ const fail = (err) => {
31
+ for (const p of pending.values())
32
+ p.reject(err);
33
+ pending.clear();
34
+ };
35
+ child.on("error", (err) => fail(err));
36
+ child.on("exit", () => fail(new Error("codex app-server terminó antes de responder")));
37
+ child.stdin.on("error", (err) => fail(err));
38
+ const rl = createInterface({ input: child.stdout });
39
+ rl.on("line", (line) => {
40
+ let msg;
41
+ try {
42
+ msg = JSON.parse(line);
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (typeof msg.id !== "number")
48
+ return;
49
+ const p = pending.get(msg.id);
50
+ if (!p)
51
+ return;
52
+ pending.delete(msg.id);
53
+ if (msg.error)
54
+ p.reject(new Error(msg.error.message ?? "error de codex app-server"));
55
+ else
56
+ p.resolve(msg.result);
57
+ });
58
+ const req = (method, params) => new Promise((resolve, reject) => {
59
+ const id = ++nextId;
60
+ pending.set(id, { resolve, reject });
61
+ child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
62
+ });
63
+ const timer = setTimeout(() => fail(new Error("codex app-server no respondió a tiempo")), timeoutMs);
64
+ try {
65
+ await req("initialize", {
66
+ clientInfo: { name: COMMAND_NAME, version: "1" },
67
+ capabilities: { experimentalApi: true },
68
+ });
69
+ child.stdin.write(`${JSON.stringify({ method: "initialized" })}\n`);
70
+ return await fn(req);
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
74
+ rl.close();
75
+ child.stdout.destroy();
76
+ child.kill();
77
+ const force = setTimeout(() => child.kill("SIGKILL"), 2_000);
78
+ force.unref();
79
+ child.unref();
80
+ }
81
+ }
82
+ function isOurCommand(command) {
83
+ return new RegExp(`${COMMAND_NAME}@\\S+ update\\b`).test(command);
84
+ }
85
+ async function ourHooks(req) {
86
+ const res = (await req("hooks/list", { cwds: [homedir()] }));
87
+ return (res.data ?? [])
88
+ .flatMap((d) => d.hooks ?? [])
89
+ .filter((h) => h.source === "user" && isOurCommand(h.command ?? ""));
90
+ }
91
+ export async function approveCodexHook(timeoutMs = 20_000) {
92
+ try {
93
+ return await withAppServer(async (req) => {
94
+ const needs = (await ourHooks(req)).filter((h) => h.trustStatus !== "trusted" && h.trustStatus !== "managed" && h.currentHash);
95
+ if (needs.length > 0) {
96
+ await req("config/batchWrite", {
97
+ edits: [
98
+ {
99
+ keyPath: "hooks.state",
100
+ value: Object.fromEntries(needs.map((h) => [h.key, { trusted_hash: h.currentHash }])),
101
+ mergeStrategy: "upsert",
102
+ },
103
+ ],
104
+ reloadUserConfig: true,
105
+ });
106
+ }
107
+ const after = await ourHooks(req);
108
+ const pending = after.filter((h) => h.trustStatus !== "trusted" && h.trustStatus !== "managed").length;
109
+ return { ok: pending === 0 && after.length > 0, approved: needs.length, pending };
110
+ }, timeoutMs);
111
+ }
112
+ catch (err) {
113
+ return {
114
+ ok: false,
115
+ approved: 0,
116
+ pending: 0,
117
+ detail: err instanceof Error ? err.message : String(err),
118
+ };
119
+ }
120
+ }
121
+ export async function codexHookTrustStatus(timeoutMs = 20_000) {
122
+ try {
123
+ return await withAppServer(async (req) => (await ourHooks(req)).map((h) => h.trustStatus ?? "unknown"), timeoutMs);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
@@ -1,9 +1,11 @@
1
1
  import { AuthRequiredError } from "../api.js";
2
2
  import { hasValidAuth, readAuth } from "../auth.js";
3
3
  import { installAutoUpdate } from "../autoupdate.js";
4
+ import { approveCodexHook, hasCodexTrustConsent, saveCodexTrustConsent } from "../codex-trust.js";
4
5
  import { bold, cyan, dim, gray, green, log, logError, red, yellow } from "../colors.js";
5
6
  import { installAll } from "../installer.js";
6
7
  import { COMMAND_NAME, formatBytes, getPackageVersion, getTargetSkillsDir } from "../lib.js";
8
+ import { confirm } from "../ui.js";
7
9
  import { runLogin } from "./login.js";
8
10
  const BANNER_WIDTH = 58;
9
11
  const SHORT_DESCRIPTIONS = {
@@ -147,6 +149,37 @@ function printNextSteps(noHook) {
147
149
  function isClaudeCode() {
148
150
  return process.env.CLAUDECODE === "1" || process.env.CLAUDE_CODE === "1";
149
151
  }
152
+ async function setUpCodex(manifest, results, opts) {
153
+ if (!opts.noHook) {
154
+ let consent = hasCodexTrustConsent();
155
+ if (!consent && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
156
+ log(` ${bold("Codex")} pide aprobar cada hook antes de ejecutarlo. El nuestro solo corre ${cyan(`${COMMAND_NAME} update`)} al abrir una sesión.`);
157
+ consent = await confirm(" ¿Lo apruebo para que el auto-update funcione en Codex?", true);
158
+ if (consent)
159
+ saveCodexTrustConsent();
160
+ }
161
+ const res = consent ? await approveCodexHook() : null;
162
+ if (res?.ok) {
163
+ log(` ${green("✓")} Codex: auto-update aprobado.`);
164
+ }
165
+ else {
166
+ if (res?.detail)
167
+ log(` ${gray(`(no pude aprobarlo solo: ${res.detail})`)}`);
168
+ log(` ${bold(yellow("Codex:"))} al abrirlo elegí ${bold("«Trust all and continue»")} ${gray("(o /hooks)")} para activar el auto-update.`);
169
+ }
170
+ }
171
+ const installedMcps = new Set(results.filter((r) => r.kind === "mcp" && r.status !== "failed").map((r) => r.name));
172
+ const remotes = Object.entries(manifest.mcps ?? {})
173
+ .filter(([name, m]) => m.url && installedMcps.has(name))
174
+ .map(([name]) => name)
175
+ .sort();
176
+ if (remotes.length > 0) {
177
+ log(` ${gray("Conectá cada MCP de Codex una vez:")}`);
178
+ for (const name of remotes)
179
+ log(` ${cyan(`codex mcp login ${name}`)}`);
180
+ }
181
+ log("");
182
+ }
150
183
  export async function runInstall(opts = {}) {
151
184
  const compact = isClaudeCode();
152
185
  if (!compact)
@@ -243,6 +276,8 @@ export async function runInstall(opts = {}) {
243
276
  if (!compact)
244
277
  log("");
245
278
  }
279
+ if (result.targets.includes("codex"))
280
+ await setUpCodex(result.manifest, result.results, opts);
246
281
  if (hasFails)
247
282
  return 1;
248
283
  if (!compact) {
@@ -3,6 +3,8 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { AuthRequiredError, fetchManifest } from "../api.js";
5
5
  import { readAuth } from "../auth.js";
6
+ import { hasAutoUpdate } from "../autoupdate.js";
7
+ import { codexHookTrustStatus } from "../codex-trust.js";
6
8
  import { bold, cyan, dim, gray, green, log, logError, logWarn, yellow } from "../colors.js";
7
9
  import { detectDrift, findMisplacedMcps, inspectMcpInTarget } from "../installer.js";
8
10
  import { COMMAND_NAME, GATEWAY_URL, getAutoUpdateLogPath, getClaudeJsonPath, getClaudeSkillsDir, getLockFilePath, getMcpScopeDir, getMcpScopeWarning, getPackageVersion, getTargetSkillsDir, readLockFile, } from "../lib.js";
@@ -27,6 +29,13 @@ function reportAutoUpdateLog() {
27
29
  catch {
28
30
  }
29
31
  }
32
+ async function reportCodexHookTrust() {
33
+ const states = await codexHookTrustStatus();
34
+ if (!states || states.every((s) => s === "trusted" || s === "managed"))
35
+ return;
36
+ logWarn(`El auto-update de Codex está sin aprobar (${states.join(", ")}): Codex no lo ejecuta. Abrí Codex y elegí «Trust all and continue» (o /hooks), o corré ${bold(`${COMMAND_NAME} install --target=codex`)} y aceptá la aprobación.`);
37
+ log("");
38
+ }
30
39
  function reportSkillBackups() {
31
40
  const backupsLog = join(dirname(getAutoUpdateLogPath()), "backups", "backups.log");
32
41
  if (!existsSync(backupsLog))
@@ -309,6 +318,8 @@ export async function runStatus() {
309
318
  log("");
310
319
  reportAutoUpdateLog();
311
320
  reportSkillBackups();
321
+ if (installedTargets.includes("codex") && hasAutoUpdate("codex"))
322
+ await reportCodexHookTrust();
312
323
  if (!auth) {
313
324
  logWarn(`Inicia sesión con: ${bold(`${COMMAND_NAME} login`)}`);
314
325
  log("");
@@ -2,7 +2,8 @@ import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "
2
2
  import { dirname } from "node:path";
3
3
  import { AuthRequiredError, ensureFreshAuth, fetchManifest } from "../api.js";
4
4
  import { hasValidAuth } from "../auth.js";
5
- import { refreshAutoUpdate } from "../autoupdate.js";
5
+ import { hasAutoUpdate, refreshAutoUpdate } from "../autoupdate.js";
6
+ import { approveCodexHook, hasCodexTrustConsent } from "../codex-trust.js";
6
7
  import { cyan, dim, gray, green, log, logError, yellow } from "../colors.js";
7
8
  import { detectDrift, installAll, summarize } from "../installer.js";
8
9
  import { COMMAND_NAME, getAutoUpdateLogPath, getLastCheckPath, getPackageVersion, } from "../lib.js";
@@ -123,6 +124,11 @@ export async function runUpdate(opts = {}) {
123
124
  }
124
125
  }
125
126
  }
127
+ async function reapproveCodexHook() {
128
+ if (!hasCodexTrustConsent() || !hasAutoUpdate("codex"))
129
+ return;
130
+ await approveCodexHook();
131
+ }
126
132
  async function runUpdateOnce(opts) {
127
133
  const manifest = await fetchManifest();
128
134
  if (!manifest) {
@@ -142,6 +148,7 @@ async function runUpdateOnce(opts) {
142
148
  markChecked();
143
149
  if (!opts.dryRun) {
144
150
  refreshAutoUpdate();
151
+ await reapproveCodexHook();
145
152
  clearAutoUpdateLog();
146
153
  }
147
154
  return 0;
@@ -173,6 +180,7 @@ async function runUpdateOnce(opts) {
173
180
  }
174
181
  summarize(result.results);
175
182
  refreshAutoUpdate();
183
+ await reapproveCodexHook();
176
184
  const failed = result.results.some((r) => r.status === "failed");
177
185
  if (!failed) {
178
186
  clearAutoUpdateLog();
package/dist/installer.js CHANGED
@@ -5,7 +5,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
5
5
  import { AuthRequiredError, fetchManifest, fetchSkillFile, recordManifestFloor } from "./api.js";
6
6
  import { dim, gray, green, logError, logWarn, red, yellow } from "./colors.js";
7
7
  import { ensureLazyProxy, lazyMcpDir, lazyProxyPath, lazyToolsPath, stdioLaunch, } from "./lazy-mcp.js";
8
- import { computeBundleHash, ensureDir, getAntigravityGeminiLegacyMcpConfigPath, getAntigravityGeminiLegacySkillsDir, getAntigravityLegacyMcpConfigPath, getAntigravityLegacySkillsDir, getClaudeSettingsPath, getConfigDir, getCursorLegacyRulesDir, getHermesBin, getLockFilePath, getMcpScopeDir, getMcpScopeWarning, getOpenCodeLegacyPromptsDir, getPackageVersion, getUserScopeMcpConfigPath, isMcpScopeEnvOverride, readLockFile, sha256File, walkSkillFiles, writeLockFile, } from "./lib.js";
8
+ import { computeBundleHash, ensureDir, getAntigravityGeminiLegacyMcpConfigPath, getAntigravityGeminiLegacySkillsDir, getAntigravityGeminiSkillsDir, getAntigravityLegacyMcpConfigPath, getAntigravityLegacySkillsDir, getClaudeSettingsPath, getConfigDir, getCursorLegacyRulesDir, getHermesBin, getLockFilePath, getMcpScopeDir, getMcpScopeWarning, getOpenCodeLegacyPromptsDir, getPackageVersion, getUserScopeMcpConfigPath, isMcpScopeEnvOverride, readLockFile, sha256File, walkSkillFiles, writeLockFile, } from "./lib.js";
9
9
  import { renderHermesMcpOps, renderMcpForTarget } from "./render.js";
10
10
  import { TARGETS, detectInstalledTargets } from "./targets.js";
11
11
  const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
@@ -686,15 +686,65 @@ function tomlMarkers(name) {
686
686
  end: `# <<< alegra-data-agent:${name} <<<`,
687
687
  };
688
688
  }
689
+ function splitTomlBlock(inner, name) {
690
+ const lines = inner.split("\n");
691
+ const isOurs = (h) => h === `[mcp_servers.${name}]` || h === `[mcp_servers.${name}.env]`;
692
+ const at = lines.findIndex((l) => {
693
+ const t = l.trim();
694
+ return t.startsWith("[") && !isOurs(tomlHeader(t));
695
+ });
696
+ if (at === -1)
697
+ return { ours: inner, foreign: "" };
698
+ return {
699
+ ours: lines.slice(0, at).join("\n").replace(/\n+$/, ""),
700
+ foreign: lines.slice(at).join("\n").replace(/\n+$/, ""),
701
+ };
702
+ }
703
+ function tomlHeader(line) {
704
+ return line.replace(/\]\s*#.*$/, "]");
705
+ }
706
+ function dropServerSubtables(content, name) {
707
+ const prefix = `[mcp_servers.${name}.`;
708
+ const out = [];
709
+ let skipping = false;
710
+ let dropped = false;
711
+ for (const line of content.split("\n")) {
712
+ const t = line.trim();
713
+ const isMarker = t.startsWith("# >>> alegra-data-agent:") || t.startsWith("# <<< alegra-data-agent:");
714
+ if (t.startsWith("[") || isMarker)
715
+ skipping = !isMarker && tomlHeader(t).startsWith(prefix);
716
+ if (skipping)
717
+ dropped = true;
718
+ else
719
+ out.push(line);
720
+ }
721
+ return dropped ? out.join("\n").replace(/\n{3,}/g, "\n\n") : content;
722
+ }
723
+ function findTomlBlock(content, name) {
724
+ const { begin, end } = tomlMarkers(name);
725
+ const bi = content.indexOf(begin);
726
+ if (bi === -1)
727
+ return null;
728
+ const ei = content.indexOf(end, bi);
729
+ if (ei === -1)
730
+ return null;
731
+ return {
732
+ start: bi,
733
+ stop: ei + end.length,
734
+ inner: content
735
+ .slice(bi + begin.length, ei)
736
+ .replace(/^\n/, "")
737
+ .replace(/\n$/, ""),
738
+ };
739
+ }
689
740
  export function upsertTomlBlock(content, name, block) {
690
741
  const { begin, end } = tomlMarkers(name);
691
742
  const fenced = `${begin}\n${block}\n${end}`;
692
- const bi = content.indexOf(begin);
693
- if (bi !== -1) {
694
- const ei = content.indexOf(end, bi);
695
- if (ei !== -1) {
696
- return content.slice(0, bi) + fenced + content.slice(ei + end.length);
697
- }
743
+ const found = findTomlBlock(content, name);
744
+ if (found) {
745
+ const { foreign } = splitTomlBlock(found.inner, name);
746
+ const moved = foreign ? `\n\n${foreign}` : "";
747
+ return content.slice(0, found.start) + fenced + moved + content.slice(found.stop);
698
748
  }
699
749
  if (content.length === 0)
700
750
  return `${fenced}\n`;
@@ -702,33 +752,24 @@ export function upsertTomlBlock(content, name, block) {
702
752
  return `${content}${sep}${fenced}\n`;
703
753
  }
704
754
  export function extractTomlBlock(content, name) {
705
- const { begin, end } = tomlMarkers(name);
706
- const bi = content.indexOf(begin);
707
- if (bi === -1)
708
- return null;
709
- const ei = content.indexOf(end, bi);
710
- if (ei === -1)
711
- return null;
712
- return content
713
- .slice(bi + begin.length, ei)
714
- .replace(/^\n/, "")
715
- .replace(/\n$/, "");
755
+ const found = findTomlBlock(content, name);
756
+ return found ? splitTomlBlock(found.inner, name).ours : null;
716
757
  }
717
758
  export function removeTomlBlock(content, name) {
718
- const { begin, end } = tomlMarkers(name);
719
- const bi = content.indexOf(begin);
720
- if (bi === -1)
759
+ const found = findTomlBlock(content, name);
760
+ if (!found)
721
761
  return content;
722
- const ei = content.indexOf(end, bi);
723
- if (ei === -1)
724
- return content;
725
- let start = bi;
726
- let stop = ei + end.length;
762
+ const { foreign } = splitTomlBlock(found.inner, name);
763
+ if (foreign) {
764
+ return dropServerSubtables(content.slice(0, found.start) + foreign + content.slice(found.stop), name);
765
+ }
766
+ let start = found.start;
767
+ let stop = found.stop;
727
768
  if (content[stop] === "\n")
728
769
  stop++;
729
770
  if (start > 0 && content[start - 1] === "\n")
730
771
  start--;
731
- return content.slice(0, start) + content.slice(stop);
772
+ return dropServerSubtables(content.slice(0, start) + content.slice(stop), name);
732
773
  }
733
774
  function registerMcpInTomlBlock(mcp, spec) {
734
775
  const existing = existsSync(spec.mcpConfigPath) ? readFileSync(spec.mcpConfigPath, "utf-8") : "";
@@ -891,6 +932,7 @@ function cleanupLegacySkillArtifacts(spec) {
891
932
  const legacySkillDirs = [
892
933
  getAntigravityLegacySkillsDir(),
893
934
  getAntigravityGeminiLegacySkillsDir(),
935
+ getAntigravityGeminiSkillsDir(),
894
936
  ];
895
937
  for (const base of legacySkillDirs) {
896
938
  for (const name of [...Object.keys(lock.skills), ...LEGACY_RENAMED_SKILLS]) {
@@ -1309,10 +1351,6 @@ export function uninstallMcp(name, opts = {}) {
1309
1351
  export function uninstallAll(opts = {}) {
1310
1352
  const lock = readLockFile();
1311
1353
  const results = [];
1312
- for (const name of Object.keys(lock.skills))
1313
- results.push(uninstallSkill(name, opts));
1314
- for (const name of Object.keys(lock.mcps ?? {}))
1315
- results.push(uninstallMcp(name, opts));
1316
1354
  if (!opts.dryRun) {
1317
1355
  for (const spec of Object.values(TARGETS)) {
1318
1356
  try {
@@ -1321,6 +1359,12 @@ export function uninstallAll(opts = {}) {
1321
1359
  catch {
1322
1360
  }
1323
1361
  }
1362
+ }
1363
+ for (const name of Object.keys(lock.skills))
1364
+ results.push(uninstallSkill(name, opts));
1365
+ for (const name of Object.keys(lock.mcps ?? {}))
1366
+ results.push(uninstallMcp(name, opts));
1367
+ if (!opts.dryRun) {
1324
1368
  if (Object.keys(readLockFile().mcps ?? {}).length === 0) {
1325
1369
  try {
1326
1370
  rmSync(lazyMcpDir(), { recursive: true, force: true });
package/dist/lib.js CHANGED
@@ -174,6 +174,9 @@ export function getOpenCodeConfigPath() {
174
174
  export function getAntigravitySkillsDir() {
175
175
  return join(homedir(), ".gemini", "antigravity-cli", "skills");
176
176
  }
177
+ export function getAntigravityConfigSkillsDir() {
178
+ return join(homedir(), ".gemini", "config", "skills");
179
+ }
177
180
  export function getAntigravityGeminiSkillsDir() {
178
181
  return join(homedir(), ".gemini", "skills");
179
182
  }
@@ -183,6 +186,9 @@ export function getAntigravityMcpConfigPath() {
183
186
  export function getGeminiSettingsPath() {
184
187
  return join(homedir(), ".gemini", "settings.json");
185
188
  }
189
+ export function getAntigravityHooksPath() {
190
+ return join(homedir(), ".gemini", "config", "hooks.json");
191
+ }
186
192
  export function getAntigravityLegacySkillsDir() {
187
193
  return join(homedir(), ".antigravity", "skills");
188
194
  }
package/dist/main.js CHANGED
@@ -43,6 +43,7 @@ async function main() {
43
43
  only: args.only.length > 0 ? args.only : undefined,
44
44
  targets: args.targets.length > 0 ? args.targets : undefined,
45
45
  noHook: args.flags.noHook,
46
+ yes: args.flags.yes,
46
47
  hookMode: "auto",
47
48
  });
48
49
  break;
package/dist/targets.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { getAntigravityGeminiSkillsDir, getAntigravityMcpConfigPath, getAntigravitySkillsDir, getClaudeMcpConfigPath, getClaudeSkillsDir, getCodexConfigPath, getCodexSkillsDir, getCursorMcpPath, getCursorSkillsDir, getHermesConfigPath, getHermesHome, getHermesSkillsDir, getOpenCodeConfigPath, getOpenCodeSkillsDir, } from "./lib.js";
4
+ import { getAntigravityConfigSkillsDir, getAntigravityMcpConfigPath, getAntigravitySkillsDir, getClaudeMcpConfigPath, getClaudeSkillsDir, getCodexConfigPath, getCodexSkillsDir, getCursorMcpPath, getCursorSkillsDir, getHermesConfigPath, getHermesHome, getHermesSkillsDir, getOpenCodeConfigPath, getOpenCodeSkillsDir, } from "./lib.js";
5
5
  export const TARGETS = {
6
6
  "claude-code": {
7
7
  id: "claude-code",
@@ -37,7 +37,7 @@ export const TARGETS = {
37
37
  id: "antigravity",
38
38
  label: "Antigravity",
39
39
  skillsDir: getAntigravitySkillsDir(),
40
- mirrorSkillsDirs: [getAntigravityGeminiSkillsDir()],
40
+ mirrorSkillsDirs: [getAntigravityConfigSkillsDir()],
41
41
  skillFormat: "folder",
42
42
  mcpConfigPath: getAntigravityMcpConfigPath(),
43
43
  mcpRootKey: "mcpServers",
@@ -76,7 +76,11 @@ export function detectInstalledTargets() {
76
76
  "claude-code": [join(home, ".claude")],
77
77
  cursor: [join(home, ".cursor")],
78
78
  opencode: [join(xdgConfig, "opencode")],
79
- antigravity: [join(home, ".gemini", "antigravity-cli"), join(home, ".gemini", "antigravity")],
79
+ antigravity: [
80
+ join(home, ".gemini", "antigravity-cli"),
81
+ join(home, ".gemini", "antigravity"),
82
+ join(home, ".gemini", "antigravity-ide"),
83
+ ],
80
84
  codex: [codexHome],
81
85
  hermes: [getHermesHome()],
82
86
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alegra-data-agent",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "description": "Agente de Data interno de Alegra. Instala skills + MCPs en tu cliente de IA con un comando.",
6
6
  "license": "UNLICENSED",