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.
@@ -0,0 +1,499 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import { fetchManifest, fetchSkillFile } from "./api.js";
5
+ import { dim, gray, green, logError, red, yellow } from "./colors.js";
6
+ import { ensureDir, getLockFilePath, getPackageVersion, readLockFile, sha256File, writeLockFile, } from "./lib.js";
7
+ import { renderMcpForTarget } from "./render.js";
8
+ import { detectInstalledTargets, TARGETS } from "./targets.js";
9
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
10
+ const MCP_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
11
+ function validateSkillName(name) {
12
+ if (!SKILL_NAME_RE.test(name)) {
13
+ throw new Error(`Nombre de skill inválido: ${JSON.stringify(name)}`);
14
+ }
15
+ }
16
+ function validateMcpName(name) {
17
+ if (!MCP_NAME_RE.test(name)) {
18
+ throw new Error(`Nombre de MCP inválido: ${JSON.stringify(name)}`);
19
+ }
20
+ }
21
+ function validateRelPath(rel) {
22
+ if (!rel || typeof rel !== "string") {
23
+ throw new Error(`Path inválido: ${JSON.stringify(rel)}`);
24
+ }
25
+ if (rel.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith("\\")) {
26
+ throw new Error(`Path absoluto rechazado: ${rel}`);
27
+ }
28
+ const segments = rel.split(/[/\\]/);
29
+ for (const s of segments) {
30
+ if (s === "" || s === "." || s === ".." || s.startsWith(".")) {
31
+ throw new Error(`Componente peligroso en path: ${rel}`);
32
+ }
33
+ }
34
+ }
35
+ function safeJoinSkillFile(skillRoot, rel) {
36
+ const candidate = resolve(skillRoot, rel);
37
+ const root = resolve(skillRoot);
38
+ if (candidate !== root && !candidate.startsWith(root + sep)) {
39
+ throw new Error(`Path traversal detectado: ${rel}`);
40
+ }
41
+ return candidate;
42
+ }
43
+ export function verifyInstalledFiles(dir, entry) {
44
+ if (!existsSync(dir))
45
+ return { ok: false, mismatches: ["carpeta no existe"] };
46
+ const mismatches = [];
47
+ for (const rel of entry.files) {
48
+ const full = join(dir, rel);
49
+ if (!existsSync(full)) {
50
+ mismatches.push(`${rel} (faltante)`);
51
+ continue;
52
+ }
53
+ const actual = sha256File(full);
54
+ const expected = entry.sha256[rel];
55
+ if (actual !== expected) {
56
+ mismatches.push(`${rel} (hash distinto)`);
57
+ }
58
+ }
59
+ return { ok: mismatches.length === 0, mismatches };
60
+ }
61
+ function sha256Buffer(buf) {
62
+ return createHash("sha256").update(buf).digest("hex");
63
+ }
64
+ async function stageSkill(skillName, entry) {
65
+ validateSkillName(skillName);
66
+ for (const rel of entry.files)
67
+ validateRelPath(rel);
68
+ const staged = [];
69
+ for (const rel of entry.files) {
70
+ const data = await fetchSkillFile(skillName, rel);
71
+ const actualHash = sha256Buffer(data);
72
+ const expectedHash = entry.sha256[rel];
73
+ if (actualHash !== expectedHash) {
74
+ throw new Error(`hash inválido para ${skillName}/${rel}: esperaba ${expectedHash.slice(0, 12)}…, recibido ${actualHash.slice(0, 12)}…`);
75
+ }
76
+ staged.push({ rel, data });
77
+ }
78
+ return staged;
79
+ }
80
+ function writeSkillToTarget(skillName, staged, spec) {
81
+ validateSkillName(skillName);
82
+ if (spec.skillFormat === "folder") {
83
+ const dst = join(spec.skillsDir, skillName);
84
+ if (existsSync(dst))
85
+ rmSync(dst, { recursive: true, force: true });
86
+ ensureDir(dst);
87
+ for (const f of staged) {
88
+ const dstFile = safeJoinSkillFile(dst, f.rel);
89
+ ensureDir(dirname(dstFile));
90
+ writeFileSync(dstFile, f.data);
91
+ }
92
+ return;
93
+ }
94
+ const entry = staged.find((f) => f.rel === "SKILL.md" || f.rel === "README.md");
95
+ if (!entry) {
96
+ throw new Error(`Para target ${spec.id} se requiere SKILL.md o README.md en la skill`);
97
+ }
98
+ const content = new TextDecoder().decode(entry.data);
99
+ const rendered = renderSkillForTargetFromContent(content, skillName, spec.skillFormat);
100
+ ensureDir(spec.skillsDir);
101
+ const dst = join(spec.skillsDir, rendered.filename);
102
+ atomicWriteFile(dst, rendered.content);
103
+ }
104
+ function renderSkillForTargetFromContent(content, skillName, format) {
105
+ if (format === "folder") {
106
+ throw new Error("renderSkillForTargetFromContent no soporta 'folder'");
107
+ }
108
+ const stripped = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, "");
109
+ const fm = parseFrontmatterMinimal(content);
110
+ if (format === "cursor-mdc") {
111
+ const desc = fm.description ?? `Skill ${skillName}`;
112
+ const cursorDesc = desc.startsWith("USE WHEN") ? desc : `USE WHEN ${desc}`;
113
+ const safeDesc = cursorDesc.replace(/\n/g, " ").replace(/"/g, '\\"');
114
+ return {
115
+ filename: `${skillName}.mdc`,
116
+ content: `---\ndescription: "${safeDesc}"\nalwaysApply: false\n---\n\n${stripped}`,
117
+ };
118
+ }
119
+ return { filename: `${skillName}.md`, content: stripped };
120
+ }
121
+ function parseFrontmatterMinimal(content) {
122
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
123
+ if (!m)
124
+ return {};
125
+ const out = {};
126
+ for (const line of m[1].split(/\r?\n/)) {
127
+ const kv = line.match(/^description:\s*(.+)$/);
128
+ if (kv) {
129
+ out.description = kv[1].replace(/^["']|["']$/g, "").trim();
130
+ return out;
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ function atomicWriteFile(path, content) {
136
+ ensureDir(dirname(path));
137
+ const tmp = `${path}.${process.pid}.tmp`;
138
+ writeFileSync(tmp, content);
139
+ renameSync(tmp, path);
140
+ }
141
+ function backupIfExists(path) {
142
+ if (!existsSync(path))
143
+ return;
144
+ const bak = `${path}.bak-${Date.now()}`;
145
+ copyFileSync(path, bak);
146
+ }
147
+ function readJsonOrEmpty(path) {
148
+ if (!existsSync(path))
149
+ return {};
150
+ try {
151
+ return JSON.parse(readFileSync(path, "utf-8"));
152
+ }
153
+ catch {
154
+ throw new Error(`${path} no es JSON válido. Arréglalo manualmente o muévelo y vuelve a instalar.`);
155
+ }
156
+ }
157
+ function registerMcpInTarget(mcp, spec) {
158
+ validateMcpName(mcp.name);
159
+ const cfg = readJsonOrEmpty(spec.mcpConfigPath);
160
+ const section = cfg[spec.mcpRootKey] ?? {};
161
+ section[mcp.name] = renderMcpForTarget(mcp, spec);
162
+ cfg[spec.mcpRootKey] = section;
163
+ backupIfExists(spec.mcpConfigPath);
164
+ atomicWriteFile(spec.mcpConfigPath, JSON.stringify(cfg, null, 2) + "\n");
165
+ }
166
+ function unregisterMcpInTarget(mcpName, spec) {
167
+ if (!existsSync(spec.mcpConfigPath))
168
+ return false;
169
+ const cfg = readJsonOrEmpty(spec.mcpConfigPath);
170
+ const section = cfg[spec.mcpRootKey];
171
+ if (!section || !(mcpName in section))
172
+ return false;
173
+ delete section[mcpName];
174
+ cfg[spec.mcpRootKey] = section;
175
+ backupIfExists(spec.mcpConfigPath);
176
+ atomicWriteFile(spec.mcpConfigPath, JSON.stringify(cfg, null, 2) + "\n");
177
+ return true;
178
+ }
179
+ function resolveTargets(opts) {
180
+ const ids = opts.targets?.length
181
+ ? opts.targets
182
+ : detectInstalledTargets();
183
+ if (ids.length === 0) {
184
+ ids.push("claude-code");
185
+ }
186
+ return ids.map((id) => TARGETS[id]);
187
+ }
188
+ export async function installSkill(name, manifest, opts = {}) {
189
+ const entry = manifest.skills[name];
190
+ if (!entry) {
191
+ return { name, kind: "skill", status: "failed", error: "no está en el manifest" };
192
+ }
193
+ const lock = readLockFile();
194
+ const prev = lock.skills[name];
195
+ const targets = resolveTargets(opts);
196
+ const targetIds = targets.map((t) => t.id);
197
+ const targetsMatch = prev?.targets &&
198
+ prev.targets.length === targetIds.length &&
199
+ prev.targets.every((t) => targetIds.includes(t));
200
+ if (prev && prev.bundleHash === entry.bundleHash && targetsMatch && !opts.force) {
201
+ const folderTarget = targets.find((t) => t.skillFormat === "folder");
202
+ if (folderTarget) {
203
+ const dst = join(folderTarget.skillsDir, name);
204
+ const drift = verifyInstalledFiles(dst, entry);
205
+ if (drift.ok)
206
+ return { name, kind: "skill", status: "unchanged", targets: targetIds };
207
+ }
208
+ else {
209
+ return { name, kind: "skill", status: "unchanged", targets: targetIds };
210
+ }
211
+ }
212
+ if (opts.dryRun) {
213
+ return {
214
+ name,
215
+ kind: "skill",
216
+ status: prev ? "updated" : "installed",
217
+ targets: targetIds,
218
+ };
219
+ }
220
+ try {
221
+ const staged = await stageSkill(name, entry);
222
+ for (const t of targets) {
223
+ writeSkillToTarget(name, staged, t);
224
+ }
225
+ lock.skills[name] = {
226
+ name,
227
+ installedFrom: getPackageVersion(),
228
+ installedAt: new Date().toISOString(),
229
+ bundleHash: entry.bundleHash,
230
+ targets: targetIds,
231
+ };
232
+ writeLockFile(lock);
233
+ return {
234
+ name,
235
+ kind: "skill",
236
+ status: prev ? "updated" : "installed",
237
+ targets: targetIds,
238
+ };
239
+ }
240
+ catch (err) {
241
+ return {
242
+ name,
243
+ kind: "skill",
244
+ status: "failed",
245
+ targets: targetIds,
246
+ error: err instanceof Error ? err.message : String(err),
247
+ };
248
+ }
249
+ }
250
+ export function installMcp(mcp, opts = {}) {
251
+ try {
252
+ validateMcpName(mcp.name);
253
+ }
254
+ catch (err) {
255
+ return {
256
+ name: mcp.name,
257
+ kind: "mcp",
258
+ status: "failed",
259
+ error: err instanceof Error ? err.message : String(err),
260
+ };
261
+ }
262
+ const lock = readLockFile();
263
+ if (!lock.mcps)
264
+ lock.mcps = {};
265
+ const wantedTargets = resolveTargets(opts).filter((t) => !mcp.targets || mcp.targets.includes(t.id));
266
+ const targetIds = wantedTargets.map((t) => t.id);
267
+ if (targetIds.length === 0) {
268
+ return {
269
+ name: mcp.name,
270
+ kind: "mcp",
271
+ status: "skipped",
272
+ error: "ningún target soportado disponible",
273
+ };
274
+ }
275
+ const prev = lock.mcps[mcp.name];
276
+ const targetsMatch = prev?.targets &&
277
+ prev.targets.length === targetIds.length &&
278
+ prev.targets.every((t) => targetIds.includes(t));
279
+ if (prev && targetsMatch && !opts.force) {
280
+ return { name: mcp.name, kind: "mcp", status: "unchanged", targets: targetIds };
281
+ }
282
+ if (opts.dryRun) {
283
+ return {
284
+ name: mcp.name,
285
+ kind: "mcp",
286
+ status: prev ? "updated" : "installed",
287
+ targets: targetIds,
288
+ };
289
+ }
290
+ try {
291
+ for (const t of wantedTargets) {
292
+ registerMcpInTarget(mcp, t);
293
+ }
294
+ lock.mcps[mcp.name] = {
295
+ name: mcp.name,
296
+ installedFrom: getPackageVersion(),
297
+ installedAt: new Date().toISOString(),
298
+ targets: targetIds,
299
+ };
300
+ writeLockFile(lock);
301
+ return {
302
+ name: mcp.name,
303
+ kind: "mcp",
304
+ status: prev ? "updated" : "installed",
305
+ targets: targetIds,
306
+ };
307
+ }
308
+ catch (err) {
309
+ return {
310
+ name: mcp.name,
311
+ kind: "mcp",
312
+ status: "failed",
313
+ targets: targetIds,
314
+ error: err instanceof Error ? err.message : String(err),
315
+ };
316
+ }
317
+ }
318
+ export async function installAll(opts = {}) {
319
+ let manifest;
320
+ try {
321
+ manifest = await fetchManifest();
322
+ }
323
+ catch (err) {
324
+ return { error: err instanceof Error ? err.message : String(err) };
325
+ }
326
+ if (!manifest)
327
+ return { error: "No se pudo cargar el manifest del gateway." };
328
+ const targets = resolveTargets(opts).map((t) => t.id);
329
+ const skillNames = opts.only && opts.only.length > 0
330
+ ? opts.only.filter((s) => manifest.skills[s])
331
+ : Object.keys(manifest.skills);
332
+ const mcpNames = opts.only && opts.only.length > 0
333
+ ? opts.only.filter((s) => manifest.mcps && manifest.mcps[s])
334
+ : Object.keys(manifest.mcps ?? {});
335
+ const results = [];
336
+ for (const name of skillNames) {
337
+ results.push(await installSkill(name, manifest, { ...opts, targets }));
338
+ }
339
+ for (const name of mcpNames) {
340
+ const mcp = manifest.mcps?.[name];
341
+ if (!mcp)
342
+ continue;
343
+ results.push(installMcp(mcp, { ...opts, targets }));
344
+ }
345
+ return { manifest, results, targets };
346
+ }
347
+ export function uninstallSkill(name, opts = {}) {
348
+ try {
349
+ validateSkillName(name);
350
+ }
351
+ catch (err) {
352
+ return {
353
+ name,
354
+ kind: "skill",
355
+ status: "failed",
356
+ error: err instanceof Error ? err.message : String(err),
357
+ };
358
+ }
359
+ const lock = readLockFile();
360
+ const prev = lock.skills[name];
361
+ if (!prev) {
362
+ return { name, kind: "skill", status: "skipped", error: "no estaba instalada" };
363
+ }
364
+ const targetIds = (prev.targets ?? ["claude-code"]);
365
+ if (!opts.dryRun) {
366
+ for (const tId of targetIds) {
367
+ const spec = TARGETS[tId];
368
+ if (spec.skillFormat === "folder") {
369
+ const dst = join(spec.skillsDir, name);
370
+ if (existsSync(dst))
371
+ rmSync(dst, { recursive: true, force: true });
372
+ }
373
+ else {
374
+ const ext = spec.skillFormat === "cursor-mdc" ? ".mdc" : ".md";
375
+ const dst = join(spec.skillsDir, `${name}${ext}`);
376
+ if (existsSync(dst))
377
+ rmSync(dst, { force: true });
378
+ }
379
+ }
380
+ delete lock.skills[name];
381
+ writeLockFile(lock);
382
+ }
383
+ return { name, kind: "skill", status: "installed", targets: targetIds };
384
+ }
385
+ export function uninstallMcp(name, opts = {}) {
386
+ try {
387
+ validateMcpName(name);
388
+ }
389
+ catch (err) {
390
+ return {
391
+ name,
392
+ kind: "mcp",
393
+ status: "failed",
394
+ error: err instanceof Error ? err.message : String(err),
395
+ };
396
+ }
397
+ const lock = readLockFile();
398
+ const prev = lock.mcps?.[name];
399
+ if (!prev || !lock.mcps) {
400
+ return { name, kind: "mcp", status: "skipped", error: "no estaba instalado" };
401
+ }
402
+ const targetIds = (prev.targets ?? ["claude-code"]);
403
+ if (!opts.dryRun) {
404
+ for (const tId of targetIds) {
405
+ unregisterMcpInTarget(name, TARGETS[tId]);
406
+ }
407
+ delete lock.mcps[name];
408
+ writeLockFile(lock);
409
+ }
410
+ return { name, kind: "mcp", status: "installed", targets: targetIds };
411
+ }
412
+ export function uninstallAll(opts = {}) {
413
+ const lock = readLockFile();
414
+ const results = [];
415
+ for (const name of Object.keys(lock.skills))
416
+ results.push(uninstallSkill(name, opts));
417
+ for (const name of Object.keys(lock.mcps ?? {}))
418
+ results.push(uninstallMcp(name, opts));
419
+ return results;
420
+ }
421
+ export function detectDrift(manifest) {
422
+ const lock = readLockFile();
423
+ const toInstall = [];
424
+ const toUpdate = [];
425
+ const unchanged = [];
426
+ const orphaned = [];
427
+ for (const name of Object.keys(manifest.skills)) {
428
+ const prev = lock.skills[name];
429
+ if (!prev) {
430
+ toInstall.push(name);
431
+ continue;
432
+ }
433
+ if (prev.bundleHash !== manifest.skills[name].bundleHash) {
434
+ toUpdate.push(name);
435
+ }
436
+ else {
437
+ unchanged.push(name);
438
+ }
439
+ }
440
+ for (const name of Object.keys(lock.skills)) {
441
+ if (!manifest.skills[name])
442
+ orphaned.push(name);
443
+ }
444
+ const mcps = {
445
+ toInstall: [],
446
+ toUpdate: [],
447
+ unchanged: [],
448
+ orphaned: [],
449
+ };
450
+ const manifestMcps = manifest.mcps ?? {};
451
+ for (const name of Object.keys(manifestMcps)) {
452
+ if (!lock.mcps?.[name])
453
+ mcps.toInstall.push(name);
454
+ else
455
+ mcps.unchanged.push(name);
456
+ }
457
+ for (const name of Object.keys(lock.mcps ?? {})) {
458
+ if (!manifestMcps[name])
459
+ mcps.orphaned.push(name);
460
+ }
461
+ return { toInstall, toUpdate, unchanged, orphaned, mcps };
462
+ }
463
+ export function summarize(results) {
464
+ let installed = 0;
465
+ let updated = 0;
466
+ let unchanged = 0;
467
+ let failed = 0;
468
+ let skipped = 0;
469
+ for (const r of results) {
470
+ if (r.status === "installed")
471
+ installed++;
472
+ else if (r.status === "updated")
473
+ updated++;
474
+ else if (r.status === "unchanged")
475
+ unchanged++;
476
+ else if (r.status === "failed")
477
+ failed++;
478
+ else if (r.status === "skipped")
479
+ skipped++;
480
+ }
481
+ process.stdout.write("\n");
482
+ if (installed > 0)
483
+ process.stdout.write(green(` ✓ ${installed} instaladas\n`));
484
+ if (updated > 0)
485
+ process.stdout.write(green(` ↻ ${updated} actualizadas\n`));
486
+ if (unchanged > 0)
487
+ process.stdout.write(gray(` · ${unchanged} sin cambios\n`));
488
+ if (skipped > 0)
489
+ process.stdout.write(yellow(` - ${skipped} omitidas\n`));
490
+ if (failed > 0)
491
+ process.stdout.write(red(` ✗ ${failed} fallidas\n`));
492
+ for (const r of results) {
493
+ if (r.status === "failed") {
494
+ logError(`${r.name}: ${r.error}`);
495
+ }
496
+ }
497
+ process.stdout.write(dim(` Lock: ${getLockFilePath()}\n`));
498
+ process.stdout.write("\n");
499
+ }