alegra-data-agent 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ PROPRIETARY — ALL RIGHTS RESERVED
2
+ Copyright (c) Alegra. Uso interno corporativo.
3
+
4
+ Este software y sus skills asociadas son propiedad exclusiva de Alegra y están
5
+ destinados únicamente al uso interno de sus empleados y contratistas autorizados.
6
+
7
+ Queda prohibido, sin autorización expresa y por escrito de Alegra:
8
+
9
+ 1. Copiar, redistribuir, sublicenciar, vender o publicar este software
10
+ o cualquiera de sus skills, total o parcialmente.
11
+ 2. Usarlo fuera del contexto laboral con Alegra.
12
+ 3. Compartir su contenido con terceros, incluyendo otras IAs, modelos,
13
+ servicios o personas no autorizadas.
14
+ 4. Derivar trabajos basados en este código o sus skills para uso externo.
15
+
16
+ Las skills contenidas en `skills-registry/` pueden incluir información de
17
+ negocio sensible (esquemas, métricas, lógica de KPIs, identificadores de
18
+ sistemas internos). Su tratamiento debe regirse por las políticas de
19
+ seguridad de la información de Alegra.
20
+
21
+ Para consultas sobre uso, contactar a: data@alegra.com
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # alegra-data-agent
2
+
3
+ Agente de Data interno de Alegra. Instala skills + MCPs en tu cliente de IA con un comando.
4
+
5
+ ## Instalación
6
+
7
+ ```sh
8
+ pnpm dlx alegra-data-agent
9
+ ```
10
+
11
+ Solo accesible para cuentas `@alegra.com` (OAuth Google). El CLI te abre el navegador para iniciar sesión.
12
+
13
+ ## Requisitos
14
+
15
+ - Node ≥ 22.6.0
16
+ - pnpm ≥ 11.1.2
17
+ - Uno de: Claude Code, Cursor, OpenCode, Antigravity
18
+
19
+ ## Soporte
20
+
21
+ Documentación y soporte para el equipo de Alegra: canal interno de BI Data.
package/dist/api.js ADDED
@@ -0,0 +1,185 @@
1
+ import { readAuth } from "./auth.js";
2
+ import { COMMAND_NAME, GATEWAY_URL, getPackageVersion } from "./lib.js";
3
+ import { verifyManifestSignature } from "./signature.js";
4
+ export class ManifestSignatureError extends Error {
5
+ constructor(reason) {
6
+ super(`Firma del manifest inválida: ${reason}. Posible compromiso del gateway.`);
7
+ this.name = "ManifestSignatureError";
8
+ }
9
+ }
10
+ export class AuthRequiredError extends Error {
11
+ constructor(message = `Necesitas autenticarte con: ${COMMAND_NAME} login`) {
12
+ super(message);
13
+ this.name = "AuthRequiredError";
14
+ }
15
+ }
16
+ export class GatewayError extends Error {
17
+ status;
18
+ path;
19
+ constructor(status, path, message) {
20
+ super(message);
21
+ this.status = status;
22
+ this.path = path;
23
+ this.name = "GatewayError";
24
+ }
25
+ }
26
+ function newRequestId() {
27
+ const bytes = new Uint8Array(8);
28
+ crypto.getRandomValues(bytes);
29
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
30
+ }
31
+ function userAgent() {
32
+ return `${COMMAND_NAME}/${getPackageVersion()} (+https://github.com/alegradev/alegra-data-agent)`;
33
+ }
34
+ function sleep(ms) {
35
+ return new Promise((r) => setTimeout(r, ms));
36
+ }
37
+ async function authedFetch(path, opts = {}) {
38
+ const auth = readAuth();
39
+ if (!auth) {
40
+ if (opts.requireAuth === false) {
41
+ return new Response(null, { status: 401 });
42
+ }
43
+ throw new AuthRequiredError();
44
+ }
45
+ const url = `${GATEWAY_URL}${path}`;
46
+ const timeoutMs = opts.timeoutMs ?? 30_000;
47
+ const maxRetries = opts.maxRetries ?? 2;
48
+ const reqId = newRequestId();
49
+ let attempt = 0;
50
+ const delays = [250, 750, 2250];
51
+ while (true) {
52
+ const ac = new AbortController();
53
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
54
+ let res;
55
+ try {
56
+ res = await fetch(url, {
57
+ headers: {
58
+ Authorization: `Bearer ${auth.token}`,
59
+ Accept: "application/json,application/octet-stream",
60
+ "User-Agent": userAgent(),
61
+ "X-Request-Id": reqId,
62
+ },
63
+ signal: ac.signal,
64
+ });
65
+ }
66
+ catch (err) {
67
+ clearTimeout(timer);
68
+ const retryable = isRetryableNetworkError(err);
69
+ if (retryable && attempt < maxRetries) {
70
+ await sleep(delays[Math.min(attempt, delays.length - 1)]);
71
+ attempt++;
72
+ continue;
73
+ }
74
+ throw mapNetworkError(err, path, timeoutMs);
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ }
79
+ if (res.status === 401) {
80
+ if (opts.requireAuth === false)
81
+ return res;
82
+ throw new AuthRequiredError(`Token expirado o inválido. Corre: ${COMMAND_NAME} login`);
83
+ }
84
+ if (res.status === 403) {
85
+ throw new GatewayError(403, path, "Forbidden — tu cuenta no es @alegra.com o tu acceso fue revocado. " +
86
+ "Si saliste de Alegra, esto es esperado. Si no, contacta a #data.");
87
+ }
88
+ if (res.status === 429) {
89
+ const ra = parseRetryAfter(res.headers.get("retry-after"));
90
+ if (ra !== null && ra <= 5_000 && attempt < maxRetries) {
91
+ await sleep(ra);
92
+ attempt++;
93
+ continue;
94
+ }
95
+ throw new GatewayError(429, path, "Rate limit excedido. Espera un minuto y vuelve a intentar.");
96
+ }
97
+ if (res.status >= 500 && res.status < 600) {
98
+ if (attempt < maxRetries) {
99
+ await sleep(delays[Math.min(attempt, delays.length - 1)]);
100
+ attempt++;
101
+ continue;
102
+ }
103
+ throw new GatewayError(res.status, path, `Gateway con error ${res.status}. Reintenta en unos segundos. Si persiste, reporta a #data (request-id ${reqId}).`);
104
+ }
105
+ if (!res.ok) {
106
+ throw new GatewayError(res.status, path, `Gateway respondió ${res.status} en ${path}`);
107
+ }
108
+ return res;
109
+ }
110
+ }
111
+ function isRetryableNetworkError(err) {
112
+ if (!(err instanceof Error))
113
+ return false;
114
+ if (err.name === "AbortError")
115
+ return false;
116
+ const m = err.message.toLowerCase();
117
+ return (m.includes("econnreset") ||
118
+ m.includes("eai_again") ||
119
+ m.includes("socket hang up") ||
120
+ m.includes("network") ||
121
+ m.includes("etimedout"));
122
+ }
123
+ function parseRetryAfter(value) {
124
+ if (!value)
125
+ return null;
126
+ const secs = Number(value);
127
+ if (Number.isFinite(secs) && secs >= 0)
128
+ return Math.min(secs * 1000, 30_000);
129
+ const date = Date.parse(value);
130
+ if (Number.isFinite(date)) {
131
+ const diff = date - Date.now();
132
+ return diff > 0 ? Math.min(diff, 30_000) : 0;
133
+ }
134
+ return null;
135
+ }
136
+ function mapNetworkError(err, path, timeoutMs) {
137
+ if (!(err instanceof Error))
138
+ return err;
139
+ const m = err.message.toLowerCase();
140
+ if (err.name === "AbortError" || m.includes("timeout")) {
141
+ return new GatewayError(0, path, `Timeout (${Math.round(timeoutMs / 1000)}s) hablando con el gateway. Verifica tu conexión.`);
142
+ }
143
+ if (m.includes("enotfound") || m.includes("getaddrinfo") || m.includes("dns")) {
144
+ return new GatewayError(0, path, `No se pudo resolver el dominio del gateway (${GATEWAY_URL}). ¿Sin internet?`);
145
+ }
146
+ if (m.includes("econnrefused")) {
147
+ return new GatewayError(0, path, `El gateway rechazó la conexión. ¿Está caído? Verifica ${GATEWAY_URL}`);
148
+ }
149
+ if (m.includes("cert") || m.includes("ssl") || m.includes("tls")) {
150
+ return new GatewayError(0, path, "Error TLS al hablar con el gateway. ¿Hay un proxy corporativo interceptando HTTPS?");
151
+ }
152
+ return new GatewayError(0, path, `Error de red al hablar con el gateway: ${err.message}`);
153
+ }
154
+ export async function fetchManifest(opts = {}) {
155
+ try {
156
+ const res = await authedFetch("/api/manifest", opts);
157
+ if (!res.ok)
158
+ return null;
159
+ const manifest = (await res.json());
160
+ const check = verifyManifestSignature(manifest, { softFail: false });
161
+ if (!check.ok) {
162
+ throw new ManifestSignatureError(check.reason);
163
+ }
164
+ return manifest;
165
+ }
166
+ catch (err) {
167
+ if (opts.requireAuth === false && err instanceof AuthRequiredError)
168
+ return null;
169
+ if (opts.requireAuth === false && err instanceof ManifestSignatureError) {
170
+ return null;
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+ export async function fetchSkillFile(skillName, relPath) {
176
+ const enc = relPath.split("/").map(encodeURIComponent).join("/");
177
+ const res = await authedFetch(`/api/skills/${encodeURIComponent(skillName)}/${enc}`);
178
+ const buffer = await res.arrayBuffer();
179
+ return new Uint8Array(buffer);
180
+ }
181
+ export async function fetchMcps(opts = {}) {
182
+ const res = await authedFetch("/api/mcps", opts);
183
+ const body = (await res.json());
184
+ return body.mcps ?? {};
185
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,262 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { dirname } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { GATEWAY_URL, getAuthFilePath } from "./lib.js";
7
+ function decodeJwtEmail(jwt) {
8
+ const parts = jwt.split(".");
9
+ if (parts.length !== 3)
10
+ return null;
11
+ try {
12
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
13
+ const json = Buffer.from(padded, "base64").toString("utf-8");
14
+ const payload = JSON.parse(json);
15
+ return typeof payload.email === "string" ? payload.email : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function constantTimeStringEq(a, b) {
22
+ const ab = Buffer.from(a, "utf-8");
23
+ const bb = Buffer.from(b, "utf-8");
24
+ if (ab.length !== bb.length)
25
+ return false;
26
+ return timingSafeEqual(ab, bb);
27
+ }
28
+ export function readAuth() {
29
+ const path = getAuthFilePath();
30
+ if (!existsSync(path))
31
+ return null;
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf-8"));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function writeAuth(record) {
40
+ const path = getAuthFilePath();
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ writeFileSync(path, JSON.stringify(record, null, 2) + "\n", "utf-8");
43
+ try {
44
+ chmodSync(path, 0o600);
45
+ }
46
+ catch {
47
+ }
48
+ }
49
+ export function clearAuth() {
50
+ const path = getAuthFilePath();
51
+ if (existsSync(path))
52
+ rmSync(path, { force: true });
53
+ }
54
+ export function hasAuth() {
55
+ return readAuth() !== null;
56
+ }
57
+ export async function performBrowserLogin(timeoutMs = 5 * 60 * 1000) {
58
+ const state = randomBytes(24).toString("hex");
59
+ return new Promise((resolve, reject) => {
60
+ let server;
61
+ let timer;
62
+ let resolved = false;
63
+ const cleanup = () => {
64
+ if (timer)
65
+ clearTimeout(timer);
66
+ if (server) {
67
+ try {
68
+ server.close(() => { });
69
+ }
70
+ catch {
71
+ }
72
+ }
73
+ };
74
+ const safeResolve = (r) => {
75
+ if (resolved)
76
+ return;
77
+ resolved = true;
78
+ cleanup();
79
+ resolve(r);
80
+ };
81
+ const safeReject = (e) => {
82
+ if (resolved)
83
+ return;
84
+ resolved = true;
85
+ cleanup();
86
+ reject(e);
87
+ };
88
+ server = createServer((req, res) => {
89
+ if (!req.url) {
90
+ res.writeHead(400);
91
+ res.end();
92
+ return;
93
+ }
94
+ const url = new URL(req.url, "http://localhost");
95
+ if (url.pathname !== "/callback") {
96
+ res.writeHead(404, { "Content-Type": "text/html" });
97
+ res.end("<h1>404</h1>");
98
+ return;
99
+ }
100
+ const token = url.searchParams.get("token");
101
+ const gotState = url.searchParams.get("state");
102
+ if (!token) {
103
+ res.writeHead(400, { "Content-Type": "text/html" });
104
+ res.end(htmlPage("error", "Falta el token en el callback. Reintenta el login."));
105
+ safeReject(new Error("Callback sin token."));
106
+ return;
107
+ }
108
+ if (!gotState || !constantTimeStringEq(gotState, state)) {
109
+ res.writeHead(400, { "Content-Type": "text/html" });
110
+ res.end(htmlPage("error", "State no coincide. Posible CSRF — descartado. Reintenta el login para refrescar el state."));
111
+ safeReject(new Error("State mismatch (posible CSRF)."));
112
+ return;
113
+ }
114
+ const emailFromJwt = decodeJwtEmail(token);
115
+ if (!emailFromJwt) {
116
+ res.writeHead(400, { "Content-Type": "text/html" });
117
+ res.end(htmlPage("error", "Token sin claim de email. Reporta a #data-security."));
118
+ safeReject(new Error("JWT sin email."));
119
+ return;
120
+ }
121
+ const record = {
122
+ email: emailFromJwt,
123
+ token,
124
+ obtainedAt: new Date().toISOString(),
125
+ };
126
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
127
+ res.end(htmlPage("success", emailFromJwt));
128
+ safeResolve(record);
129
+ });
130
+ server.on("error", (err) => {
131
+ if (err.code === "EADDRINUSE") {
132
+ safeReject(new Error("Otro proceso está usando el puerto local. Cierra cualquier 'alegra-data-agent login' previo o reinicia el shell."));
133
+ }
134
+ else if (err.code === "EACCES") {
135
+ safeReject(new Error("Sin permisos para abrir un puerto local (necesita >= 1024)."));
136
+ }
137
+ else {
138
+ safeReject(new Error(`Error en el servidor local: ${err.message}`));
139
+ }
140
+ });
141
+ server.listen(0, "127.0.0.1", () => {
142
+ const address = server.address();
143
+ if (typeof address !== "object" || !address) {
144
+ cleanup();
145
+ reject(new Error("No se pudo determinar el puerto."));
146
+ return;
147
+ }
148
+ const port = address.port;
149
+ const authUrl = new URL(`${GATEWAY_URL}/api/cli/auth`);
150
+ authUrl.searchParams.set("port", String(port));
151
+ authUrl.searchParams.set("state", state);
152
+ const u = authUrl.toString();
153
+ process.stdout.write("\n" +
154
+ " ╭────────────────────────────────────────────────────────────╮\n" +
155
+ " │ Inicia sesión con tu cuenta @alegra.com en el navegador │\n" +
156
+ " ╰────────────────────────────────────────────────────────────╯\n" +
157
+ "\n" +
158
+ " Intento abrir tu navegador. Si no se abre solo, copia toda\n" +
159
+ " esta URL (triple-click selecciona la línea completa) y\n" +
160
+ " pégala en tu navegador:\n" +
161
+ "\n" +
162
+ " ────────────────────────────────────────────────────────────\n" +
163
+ `${u}\n` +
164
+ " ────────────────────────────────────────────────────────────\n" +
165
+ "\n" +
166
+ ` Esperando hasta ${Math.round(timeoutMs / 1000)}s a que completes el login…\n` +
167
+ "\n");
168
+ openBrowser(u);
169
+ timer = setTimeout(() => {
170
+ safeReject(new Error(`Timeout: pasaron ${Math.round(timeoutMs / 1000)}s sin recibir la auth. ` +
171
+ "Si abriste el link en otra máquina sin conexión al CLI, debes correr el comando " +
172
+ "desde la misma máquina donde abrirás el browser. Reintenta."));
173
+ }, timeoutMs);
174
+ });
175
+ });
176
+ }
177
+ function isWsl() {
178
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
179
+ }
180
+ function openBrowser(url) {
181
+ const platform = process.platform;
182
+ const candidates = [];
183
+ if (platform === "darwin") {
184
+ candidates.push({ cmd: "open", args: [url] });
185
+ }
186
+ else if (platform === "win32") {
187
+ candidates.push({
188
+ cmd: "powershell.exe",
189
+ args: ["-NoProfile", "-Command", `Start-Process '${url.replace(/'/g, "''")}'`],
190
+ });
191
+ candidates.push({
192
+ cmd: "rundll32.exe",
193
+ args: ["url.dll,FileProtocolHandler", url],
194
+ });
195
+ candidates.push({ cmd: "cmd", args: ["/c", "start", '""', `"${url}"`] });
196
+ }
197
+ else if (isWsl()) {
198
+ candidates.push({
199
+ cmd: "powershell.exe",
200
+ args: ["-NoProfile", "-Command", `Start-Process '${url.replace(/'/g, "''")}'`],
201
+ });
202
+ candidates.push({
203
+ cmd: "rundll32.exe",
204
+ args: ["url.dll,FileProtocolHandler", url],
205
+ });
206
+ candidates.push({ cmd: "cmd.exe", args: ["/c", "start", '""', `"${url}"`] });
207
+ candidates.push({ cmd: "xdg-open", args: [url] });
208
+ }
209
+ else {
210
+ candidates.push({ cmd: "xdg-open", args: [url] });
211
+ candidates.push({ cmd: "gnome-open", args: [url] });
212
+ candidates.push({ cmd: "kde-open", args: [url] });
213
+ }
214
+ const tryNext = (i) => {
215
+ if (i >= candidates.length)
216
+ return;
217
+ const { cmd, args } = candidates[i];
218
+ try {
219
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
220
+ child.on("error", () => tryNext(i + 1));
221
+ child.unref();
222
+ }
223
+ catch {
224
+ tryNext(i + 1);
225
+ }
226
+ };
227
+ tryNext(0);
228
+ }
229
+ function htmlPage(kind, subject) {
230
+ const ok = kind === "success";
231
+ const color = ok ? "#10b981" : "#ef4444";
232
+ const title = ok ? "Autenticación exitosa" : "Error";
233
+ const body = ok
234
+ ? `Conectaste como <strong>${escapeHtml(subject)}</strong>.<br/>Ya puedes cerrar esta pestaña y volver al terminal.`
235
+ : `<strong>${escapeHtml(subject)}</strong><br/>Revisa el terminal e intenta de nuevo.`;
236
+ return `<!doctype html>
237
+ <html lang="es">
238
+ <head>
239
+ <meta charset="utf-8" />
240
+ <title>${title}</title>
241
+ <style>
242
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
243
+ background: #0b0d10; color: #e6e6e6; margin: 0;
244
+ display: grid; place-items: center; min-height: 100vh; }
245
+ .card { max-width: 480px; padding: 2.5rem; text-align: center; }
246
+ .icon { font-size: 3rem; color: ${color}; }
247
+ h1 { font-size: 1.5rem; margin: 1rem 0 .5rem; }
248
+ p { color: #9ca3af; line-height: 1.6; }
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <div class="card">
253
+ <div class="icon">${ok ? "✓" : "✗"}</div>
254
+ <h1>${title}</h1>
255
+ <p>${body}</p>
256
+ </div>
257
+ </body>
258
+ </html>`;
259
+ }
260
+ function escapeHtml(s) {
261
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
262
+ }
package/dist/colors.js ADDED
@@ -0,0 +1,35 @@
1
+ const SUPPORTS_COLOR = process.stdout.isTTY &&
2
+ process.env.TERM !== "dumb" &&
3
+ process.env.NO_COLOR === undefined;
4
+ const wrap = (open, close) => (s) => (SUPPORTS_COLOR ? `${open}${s}${close}` : String(s));
5
+ export const bold = wrap("\x1b[1m", "\x1b[22m");
6
+ export const dim = wrap("\x1b[2m", "\x1b[22m");
7
+ export const red = wrap("\x1b[31m", "\x1b[39m");
8
+ export const green = wrap("\x1b[32m", "\x1b[39m");
9
+ export const yellow = wrap("\x1b[33m", "\x1b[39m");
10
+ export const blue = wrap("\x1b[34m", "\x1b[39m");
11
+ export const magenta = wrap("\x1b[35m", "\x1b[39m");
12
+ export const cyan = wrap("\x1b[36m", "\x1b[39m");
13
+ export const gray = wrap("\x1b[90m", "\x1b[39m");
14
+ export const HIDE_CURSOR = SUPPORTS_COLOR ? "\x1b[?25l" : "";
15
+ export const SHOW_CURSOR = SUPPORTS_COLOR ? "\x1b[?25h" : "";
16
+ export const CLEAR_LINE = SUPPORTS_COLOR ? "\r\x1b[K" : "\r";
17
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
18
+ export function write(s) {
19
+ process.stdout.write(s);
20
+ }
21
+ export function log(s = "") {
22
+ process.stdout.write(s + "\n");
23
+ }
24
+ export function logError(s) {
25
+ process.stderr.write(red("✗ ") + s + "\n");
26
+ }
27
+ export function logWarn(s) {
28
+ process.stdout.write(yellow("⚠ ") + s + "\n");
29
+ }
30
+ export function logOk(s) {
31
+ process.stdout.write(green("✓ ") + s + "\n");
32
+ }
33
+ export function logInfo(s) {
34
+ process.stdout.write(cyan("ℹ ") + s + "\n");
35
+ }
@@ -0,0 +1,35 @@
1
+ import { fetchManifest } from "../api.js";
2
+ import { detectDrift } from "../installer.js";
3
+ import { hasAuth } from "../auth.js";
4
+ import { bold, cyan, dim, yellow } from "../colors.js";
5
+ import { COMMAND_NAME } from "../lib.js";
6
+ export async function runCheck() {
7
+ try {
8
+ if (!hasAuth())
9
+ return 0;
10
+ const manifest = await fetchManifest({ requireAuth: false, timeoutMs: 5000 });
11
+ if (!manifest)
12
+ return 0;
13
+ const drift = detectDrift(manifest);
14
+ const total = drift.toInstall.length + drift.toUpdate.length;
15
+ if (total === 0)
16
+ return 0;
17
+ const lines = [];
18
+ lines.push("");
19
+ lines.push(yellow("🟡 Skills de Data Alegra: ") + bold(`${total} actualizaciones disponibles`));
20
+ for (const name of drift.toInstall) {
21
+ lines.push(` ${cyan("+")} ${name} ${dim("(nueva)")}`);
22
+ }
23
+ for (const name of drift.toUpdate) {
24
+ lines.push(` ${cyan("↻")} ${name} ${dim("(versión nueva)")}`);
25
+ }
26
+ lines.push("");
27
+ lines.push(` ${dim("Actualiza con:")} ${bold(`${COMMAND_NAME} update`)}`);
28
+ lines.push("");
29
+ process.stdout.write(lines.join("\n"));
30
+ return 0;
31
+ }
32
+ catch {
33
+ return 0;
34
+ }
35
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { COMMAND_NAME, getClaudeSettingsPath, getPackageVersion, PRODUCT_NAME, } from "../lib.js";
3
+ import { logError, logOk, logWarn } from "../colors.js";
4
+ function hookCommandFor(mode) {
5
+ const v = getPackageVersion();
6
+ const parts = v.split(".");
7
+ const major = parts[0] ?? "0";
8
+ const minor = parts[1] ?? "0";
9
+ const range = `~${major}.${minor}.0`;
10
+ const verb = mode === "auto" ? "update --yes" : "check";
11
+ return `pnpm dlx ${COMMAND_NAME}@${range} ${verb}`;
12
+ }
13
+ const HOOK_SIGNATURE = COMMAND_NAME;
14
+ function readSettings() {
15
+ const path = getClaudeSettingsPath();
16
+ if (!existsSync(path))
17
+ return {};
18
+ try {
19
+ return JSON.parse(readFileSync(path, "utf-8"));
20
+ }
21
+ catch {
22
+ logError(`No se pudo parsear ${path}. Revisa el JSON manualmente.`);
23
+ return {};
24
+ }
25
+ }
26
+ function writeSettings(s) {
27
+ writeFileSync(getClaudeSettingsPath(), JSON.stringify(s, null, 2) + "\n", "utf-8");
28
+ }
29
+ function isOurMatcher(m) {
30
+ if (!m || typeof m !== "object")
31
+ return false;
32
+ const hooks = m.hooks;
33
+ if (!Array.isArray(hooks))
34
+ return false;
35
+ return hooks.some((h) => h &&
36
+ typeof h === "object" &&
37
+ h.type === "command" &&
38
+ typeof h.command === "string" &&
39
+ h.command.includes(HOOK_SIGNATURE));
40
+ }
41
+ function buildOurMatcher(command) {
42
+ return { hooks: [{ type: "command", command }] };
43
+ }
44
+ export function runHookInstall(mode = "check") {
45
+ const settings = readSettings();
46
+ settings.hooks = settings.hooks ?? {};
47
+ const command = hookCommandFor(mode);
48
+ const existing = settings.hooks.SessionStart;
49
+ const compact = process.env.CLAUDECODE === "1" || process.env.CLAUDE_CODE === "1";
50
+ if (typeof existing === "string") {
51
+ if (!existing.includes(HOOK_SIGNATURE)) {
52
+ logWarn(`Ya hay un SessionStart hook configurado (formato string legacy):\n ${existing}\n` +
53
+ ` No lo sobrescribo. Conviértelo a formato array manualmente.`);
54
+ return 1;
55
+ }
56
+ settings.hooks.SessionStart = [buildOurMatcher(command)];
57
+ writeSettings(settings);
58
+ if (!compact)
59
+ logOk(`Hook migrado a formato array y configurado en modo '${mode}'.`);
60
+ return 0;
61
+ }
62
+ const matchers = Array.isArray(existing)
63
+ ? existing.filter((m) => !!m && typeof m === "object" && Array.isArray(m.hooks))
64
+ : [];
65
+ const previousOurs = matchers.find(isOurMatcher);
66
+ if (previousOurs) {
67
+ const sameCommand = previousOurs.hooks.some((h) => h.type === "command" && h.command === command);
68
+ if (sameCommand) {
69
+ if (!compact)
70
+ logOk(`El hook ya está instalado en modo '${mode}'.`);
71
+ return 0;
72
+ }
73
+ }
74
+ const others = matchers.filter((m) => !isOurMatcher(m));
75
+ others.push(buildOurMatcher(command));
76
+ settings.hooks.SessionStart = others;
77
+ writeSettings(settings);
78
+ if (!compact) {
79
+ const description = mode === "auto"
80
+ ? "actualizará automáticamente al abrir Claude Code"
81
+ : "avisará al iniciar Claude Code cuando haya updates";
82
+ logOk(`Hook instalado (modo '${mode}'). ${COMMAND_NAME} ${description}.`);
83
+ }
84
+ return 0;
85
+ }
86
+ export function runHookUninstall() {
87
+ const settings = readSettings();
88
+ const existing = settings.hooks?.SessionStart;
89
+ if (existing === undefined) {
90
+ logWarn("No había hook que desinstalar.");
91
+ return 0;
92
+ }
93
+ if (typeof existing === "string") {
94
+ if (!existing.includes(HOOK_SIGNATURE)) {
95
+ logWarn(`El SessionStart hook actual no es de ${PRODUCT_NAME}. No lo toco.`);
96
+ return 1;
97
+ }
98
+ delete settings.hooks.SessionStart;
99
+ writeSettings(settings);
100
+ logOk("Hook removido.");
101
+ return 0;
102
+ }
103
+ if (!Array.isArray(existing)) {
104
+ logWarn("No había hook que desinstalar.");
105
+ return 0;
106
+ }
107
+ const filtered = existing.filter((m) => !isOurMatcher(m));
108
+ if (filtered.length === existing.length) {
109
+ logWarn(`No encontré ningún SessionStart hook de ${PRODUCT_NAME}. No lo toco.`);
110
+ return 1;
111
+ }
112
+ if (filtered.length === 0) {
113
+ delete settings.hooks.SessionStart;
114
+ }
115
+ else {
116
+ settings.hooks.SessionStart = filtered;
117
+ }
118
+ writeSettings(settings);
119
+ logOk("Hook removido.");
120
+ return 0;
121
+ }
122
+ export { hookCommandFor };