ozali 0.4.0 → 0.5.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.
@@ -1,22 +1,87 @@
1
1
  // commands.mjs — implementación de init / doctor / update / sync.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import os from "node:os";
4
5
  import {
5
6
  c, ok, warn, err, info, step,
6
7
  SKILL_SRC, TEMPLATES_SRC, exists, ensureDir, copyDir, readJSON, writeJSON,
7
- ensureGitignore, tryExec, spawnCmd, which,
8
- projectName, pkgVersion, DEFAULT_KNOWLEDGE, HOME,
8
+ ensureGitignore, tryExec, spawnCmd, which, engramAssetName,
9
+ projectName, pkgVersion, DEFAULT_KNOWLEDGE, HOME, openURL,
9
10
  } from "./util.mjs";
10
11
  import { detectAll } from "./detect.mjs";
11
12
  import { ask, confirm, select } from "./prompt.mjs";
12
13
 
13
14
  const CONFIG_PATH = (cwd) => path.join(cwd, ".ozali", "config.json");
15
+ const TEAM_CLOUD_PATH = (cwd) => path.join(cwd, ".ozali", "cloud.json");
16
+ const ENGRAM_CONFIG_PATH = (cwd) => path.join(cwd, ".engram", "config.json");
17
+ const CLOUD_TOKEN_ENV = "ENGRAM_CLOUD_TOKEN";
18
+ const CLOUD_AUTOSYNC_ENV = "ENGRAM_CLOUD_AUTOSYNC";
19
+ const CLOUD_SERVER_ENV = "ENGRAM_CLOUD_SERVER";
14
20
 
15
21
  function skillTarget(cwd, scope) {
16
22
  const base = scope === "global" ? path.join(process.env.HOME || "", ".claude") : path.join(cwd, ".claude");
17
23
  return path.join(base, "skills", "ozali");
18
24
  }
19
25
 
26
+ function readTeamCloud(cwd) {
27
+ return readJSON(TEAM_CLOUD_PATH(cwd));
28
+ }
29
+
30
+ function writeTeamCloud(cwd, cloud) {
31
+ writeJSON(TEAM_CLOUD_PATH(cwd), {
32
+ server: cloud.server,
33
+ project: cloud.project,
34
+ enrolled: !!cloud.enabled,
35
+ auth_required: cloud.authRequired !== false,
36
+ token_env: cloud.tokenEnv || CLOUD_TOKEN_ENV,
37
+ autosync_env: cloud.autosyncEnv || CLOUD_AUTOSYNC_ENV,
38
+ dashboard: cloudDashboardURL(cloud.server),
39
+ updated_at: new Date().toISOString(),
40
+ });
41
+ }
42
+
43
+ function firstNonEmpty(...values) {
44
+ return values.find((value) => typeof value === "string" && value.trim()) || null;
45
+ }
46
+
47
+ function firstLine(text, fallback = "") {
48
+ if (!text) return fallback;
49
+ const line = String(text).split(/\r?\n/).map((part) => part.trim()).find(Boolean);
50
+ return line || fallback;
51
+ }
52
+
53
+ function printIndented(text) {
54
+ if (!text) return;
55
+ for (const line of String(text).split(/\r?\n/)) console.log(` ${c.dim(line)}`);
56
+ }
57
+
58
+ function cloudDashboardURL(server) {
59
+ if (!server) return null;
60
+ return server.replace(/\/+$/, "") + "/dashboard";
61
+ }
62
+
63
+ function hasCloudToken() {
64
+ return !!firstNonEmpty(process.env[CLOUD_TOKEN_ENV]);
65
+ }
66
+
67
+ function extractReasonCode(text) {
68
+ if (!text) return null;
69
+ const match = firstLine(text).match(/(?:reason(?:_code)?|reasonCode)\s*[:=]\s*([a-z_]+)/i);
70
+ return match ? match[1].toLowerCase() : null;
71
+ }
72
+
73
+ function syncCloudProject(cwd, project) {
74
+ return tryExec("engram", ["sync", "--cloud", "--project", project], { cwd });
75
+ }
76
+
77
+ function cloudStatusSnapshot(cwd, project) {
78
+ return {
79
+ syncStatus: tryExec("engram", ["sync", "--cloud", "--status", "--project", project], { cwd }),
80
+ upgradeStatus: tryExec("engram", ["cloud", "upgrade", "status", "--project", project], { cwd }),
81
+ conflictsStats: tryExec("engram", ["conflicts", "stats", "--project", project], { cwd }),
82
+ };
83
+ }
84
+
20
85
  // ============================================================ init ===========
21
86
  export async function init(cwd, opts) {
22
87
  step("ozali init — bootstrap del proyecto");
@@ -88,7 +153,13 @@ export async function init(cwd, opts) {
88
153
  // Engram Cloud (opt-in) — réplica de equipo además del git-sync. Solo si Engram quedó disponible.
89
154
  let cloud = { enabled: false };
90
155
  if (memoryMode === "hybrid" && !opts.dryRun) {
91
- cloud = await maybeEnableEngramCloud(projectName(cwd), opts);
156
+ const teamCloud = readTeamCloud(cwd);
157
+ if (teamCloud && teamCloud.enrolled) {
158
+ // Fase 1: dev nuevo en un repo que ya tiene .ozali/cloud.json del equipo
159
+ cloud = await connectTeamCloud(cwd, teamCloud, opts);
160
+ } else {
161
+ cloud = await maybeEnableEngramCloud(cwd, projectName(cwd), opts);
162
+ }
92
163
  }
93
164
 
94
165
  if (opts.dryRun) { warn("--dry-run: no escribo nada. Plan mostrado arriba."); return 0; }
@@ -115,7 +186,7 @@ export async function init(cwd, opts) {
115
186
  if (!opts.noJarvis) {
116
187
  const proj = projectName(cwd);
117
188
  // Fija el proyecto para escrituras deterministas de memoria (se deriva igual por cada miembro).
118
- writeJSON(path.join(cwd, ".engram", "config.json"), { project_name: proj });
189
+ writeJSON(ENGRAM_CONFIG_PATH(cwd), { project_name: proj });
119
190
  ok(`Proyecto de memoria fijado en ${c.bold(".engram/config.json")} (${proj}).`);
120
191
  if (agent === "claude-code" || agent === "both") ensureJarvisClaudeCode(cwd);
121
192
  if (agent === "opencode" || agent === "both") ensureJarvisOpencode(cwd);
@@ -123,7 +194,7 @@ export async function init(cwd, opts) {
123
194
 
124
195
  // 3) gitignore del histórico aislado
125
196
  if (env.git.isRepo) {
126
- const { added } = ensureGitignore(cwd, [".ozali/", ".engram/"]);
197
+ const { added } = ensureGitignore(cwd, [".ozali/*", "!.ozali/cloud.json", ".engram/"]);
127
198
  if (added.length) ok(`.gitignore actualizado: ${added.join(", ")} (histórico aislado del repo principal).`);
128
199
  else info(".gitignore ya aislaba el histórico.");
129
200
  }
@@ -167,14 +238,28 @@ export async function init(cwd, opts) {
167
238
 
168
239
  /**
169
240
  * Instala Engram con la mejor ruta disponible para el SO actual.
170
- * macOS/Linux: Homebrew (recomendado) o Go install como fallback.
241
+ * Linux: binario precompilado (auto-descarga, sin toolchain) con brew/go como alternativas.
242
+ * macOS: Homebrew → Go → binario precompilado como fallback.
171
243
  * Windows: Go install (recomendado) o binario manual.
172
244
  * Devuelve true si el binario quedó disponible en PATH.
173
245
  */
174
246
  function installEngram() {
175
247
  const plat = process.platform;
176
248
 
177
- if (plat === "darwin" || plat === "linux") {
249
+ if (plat === "linux") {
250
+ // En Linux Homebrew es poco común: el binario precompilado es la vía universal sin toolchain.
251
+ if (installEngramFromTarball()) return true;
252
+ if (which("brew")) {
253
+ info("Instalando: " + c.bold("brew install gentleman-programming/tap/engram"));
254
+ spawnCmd("brew", ["install", "gentleman-programming/tap/engram"]);
255
+ } else if (which("go")) {
256
+ info("Compilando con Go: " + c.bold("go install github.com/Gentleman-Programming/engram/cmd/engram@latest"));
257
+ spawnCmd("go", ["install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"]);
258
+ } else {
259
+ warn("No pude instalar Engram automáticamente. Sigue las instrucciones de abajo.");
260
+ return false;
261
+ }
262
+ } else if (plat === "darwin") {
178
263
  if (which("brew")) {
179
264
  info("Instalando: " + c.bold("brew install gentleman-programming/tap/engram"));
180
265
  spawnCmd("brew", ["install", "gentleman-programming/tap/engram"]);
@@ -182,8 +267,10 @@ function installEngram() {
182
267
  info("Homebrew no encontrado — instalando con Go:");
183
268
  info(" " + c.bold("go install github.com/Gentleman-Programming/engram/cmd/engram@latest"));
184
269
  spawnCmd("go", ["install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"]);
270
+ } else if (installEngramFromTarball()) {
271
+ return true;
185
272
  } else {
186
- warn("No se encontró Homebrew ni Go. Opciones de instalación:");
273
+ warn("No se encontró Homebrew ni Go y falló la descarga del binario. Opciones:");
187
274
  info(" a) Homebrew " + c.dim("(recomendado)") + ": " + c.cyan("https://brew.sh") + " → " + c.bold("brew install gentleman-programming/tap/engram"));
188
275
  info(" b) Binario precompilado: " + c.cyan("https://github.com/Gentleman-Programming/engram/releases"));
189
276
  return false;
@@ -210,11 +297,159 @@ function installEngram() {
210
297
  return false;
211
298
  }
212
299
 
300
+ const ENGRAM_RELEASES_API = "https://api.github.com/repos/Gentleman-Programming/engram/releases/latest";
301
+ const ENGRAM_RELEASES_DL = "https://github.com/Gentleman-Programming/engram/releases/download";
302
+
303
+ /** Última versión publicada de Engram (sin la "v" inicial), o null si no hay red. */
304
+ function latestEngramVersion() {
305
+ let raw = null;
306
+ if (which("curl")) raw = tryExec("curl", ["-fsSL", ENGRAM_RELEASES_API]);
307
+ else if (which("wget")) raw = tryExec("wget", ["-qO-", ENGRAM_RELEASES_API]);
308
+ if (!raw) return null;
309
+ try {
310
+ const tag = JSON.parse(raw).tag_name;
311
+ return tag ? String(tag).replace(/^v/, "") : null;
312
+ } catch { return null; }
313
+ }
314
+
315
+ /** Descarga url → dest con curl (o wget como fallback). Devuelve true si tuvo éxito. */
316
+ function download(url, dest) {
317
+ if (which("curl")) return spawnCmd("curl", ["-fL", "--retry", "2", "-o", dest, url]) === 0;
318
+ if (which("wget")) return spawnCmd("wget", ["-O", dest, url]) === 0;
319
+ warn("No se encontró curl ni wget para descargar el binario de Engram.");
320
+ return false;
321
+ }
322
+
323
+ /** Busca recursivamente un ejecutable llamado "engram" dentro de dir (1 nivel basta). */
324
+ function findEngramBinary(dir) {
325
+ let entries;
326
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
327
+ for (const e of entries) {
328
+ const full = path.join(dir, e.name);
329
+ if (e.isFile() && e.name === "engram") return full;
330
+ if (e.isDirectory()) {
331
+ const nested = findEngramBinary(full);
332
+ if (nested) return nested;
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * Instala Engram bajando el binario precompilado del release más reciente (linux/macOS).
340
+ * Lo coloca en ~/.local/bin (sin sudo) y lo antepone al PATH del proceso para que los
341
+ * `engram setup <agente>` posteriores lo encuentren en esta misma corrida.
342
+ * Node-14-safe: usa curl/wget + tar vía execFileSync (sin fetch global). Devuelve true si quedó listo.
343
+ */
344
+ function installEngramFromTarball() {
345
+ const plat = process.platform;
346
+ if (plat !== "linux" && plat !== "darwin") return false;
347
+
348
+ // Falla rápido si la arquitectura no tiene binario publicado (versión irrelevante para esta validación).
349
+ if (!engramAssetName(plat, process.arch, "0")) {
350
+ warn(`Arquitectura no soportada para el binario precompilado (${process.arch}).`);
351
+ return false;
352
+ }
353
+
354
+ const version = latestEngramVersion();
355
+ if (!version) {
356
+ warn("No pude resolver la última versión de Engram (¿sin red o sin curl/wget?).");
357
+ return false;
358
+ }
359
+
360
+ const asset = engramAssetName(plat, process.arch, version);
361
+ const url = `${ENGRAM_RELEASES_DL}/v${version}/${asset}`;
362
+ info(`Descargando binario precompilado de Engram ${c.bold("v" + version)} (${process.arch})…`);
363
+
364
+ let tmpDir;
365
+ try { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ozali-engram-")); }
366
+ catch { warn("No pude crear un directorio temporal para la descarga."); return false; }
367
+
368
+ const tarball = path.join(tmpDir, asset);
369
+ if (!download(url, tarball)) { warn("Falló la descarga del binario de Engram."); return false; }
370
+
371
+ if (spawnCmd("tar", ["-xzf", tarball, "-C", tmpDir]) !== 0) {
372
+ warn("Falló la extracción del tarball de Engram (¿tar disponible?).");
373
+ return false;
374
+ }
375
+
376
+ const binSrc = findEngramBinary(tmpDir);
377
+ if (!binSrc) { warn("No encontré el binario engram dentro del tarball."); return false; }
378
+
379
+ const destDir = path.join(HOME, ".local", "bin");
380
+ const dest = path.join(destDir, "engram");
381
+ try {
382
+ ensureDir(destDir);
383
+ fs.copyFileSync(binSrc, dest);
384
+ fs.chmodSync(dest, 0o755);
385
+ } catch (e) {
386
+ warn(`No pude instalar el binario en ${destDir} (${e.message}).`);
387
+ return false;
388
+ }
389
+
390
+ // Disponible en esta corrida para los `engram setup` que vienen después.
391
+ const onPath = (process.env.PATH || "").split(path.delimiter).includes(destDir);
392
+ if (!onPath) process.env.PATH = destDir + path.delimiter + (process.env.PATH || "");
393
+
394
+ ok(`Engram instalado en ${c.bold(dest)}.`);
395
+ if (!onPath) {
396
+ warn(`${c.bold(destDir)} no estaba en tu PATH. Para usar ${c.bold("engram")} fuera del agente, añádelo a tu shell:`);
397
+ console.log(` ${c.dim('export PATH="$HOME/.local/bin:$PATH"')}`);
398
+ }
399
+ return true;
400
+ }
401
+
402
+ /**
403
+ * Fase 1: Onboarding de equipo. Un dev nuevo hace `ozali init` en un repo que ya tiene
404
+ * .ozali/cloud.json (commiteable). Detecta la cloud del equipo y ofrece conectarse en 1 paso.
405
+ */
406
+ async function connectTeamCloud(cwd, cloudMeta, opts) {
407
+ const project = cloudMeta.project || projectName(cwd);
408
+ ok(`Engram Cloud del equipo detectado (servidor: ${c.bold(cloudMeta.server || "por defecto")})`);
409
+ info(` → Proyecto: "${project}"`);
410
+ const connect = await confirm("¿Conectarte a la memoria del equipo?", true);
411
+ if (!connect) {
412
+ info("Cloud del equipo omitido. Puedes conectarte después con " + c.bold("ozali cloud config") + ".");
413
+ return { enabled: false, server: cloudMeta.server };
414
+ }
415
+ const token = await ask("Token de autenticación (ENGRAM_CLOUD_TOKEN)");
416
+ if (!token) {
417
+ warn("Sin token no se puede conectar (modo autenticado obligatorio). Continúo con git-sync.");
418
+ return { enabled: false, server: cloudMeta.server };
419
+ }
420
+ const server = cloudMeta.server || "http://127.0.0.1:18080";
421
+ info(`Configurando Engram Cloud → ${server}`);
422
+ spawnCmd("engram", ["cloud", "config", "--server", server]);
423
+ process.env[CLOUD_TOKEN_ENV] = token;
424
+ info(`Enrolando el proyecto "${project}"…`);
425
+ if (spawnCmd("engram", ["cloud", "enroll", project]) !== 0) {
426
+ warn("No se pudo enrolar. Verifica el token y el servidor. Continúo con git-sync.");
427
+ return { enabled: false, server };
428
+ }
429
+ writeTeamCloud(cwd, { enabled: true, server, project, authRequired: true });
430
+ configureCloudAutosync(cwd, opts);
431
+ persistCloudToken(token, opts);
432
+ ok("Conectado a Engram Cloud del equipo.");
433
+ // Recibir la memoria del equipo (pull desde cloud)
434
+ info("Recibiendo memoria del equipo…");
435
+ const pullOut = tryExec("engram", ["sync", "--cloud", "--import", "--project", project], { cwd });
436
+ if (pullOut !== null) {
437
+ ok("Memoria del equipo recibida.");
438
+ if (pullOut.trim()) printIndented(pullOut);
439
+ // Importar chunks locales
440
+ if (spawnCmd("engram", ["sync", "--import"], { cwd }) === 0) ok("Memorias importadas a Engram local.");
441
+ } else {
442
+ warn("No se pudo recibir la memoria del equipo. Corre " + c.bold("ozali sync --cloud --import") + " más tarde.");
443
+ }
444
+ return { enabled: true, server, token };
445
+ }
446
+
213
447
  /**
214
448
  * Engram Cloud opt-in: réplica de equipo en tiempo real, adicional al git-sync.
215
- * Configura el servidor y enrola el proyecto. Devuelve { enabled, server }.
449
+ * Modo autenticado obligatorio: siempre pide token. Configura autosync en el agente.
450
+ * Devuelve { enabled, server, token }.
216
451
  */
217
- async function maybeEnableEngramCloud(project, opts) {
452
+ async function maybeEnableEngramCloud(cwd, project, opts) {
218
453
  if (opts.yes) return { enabled: false };
219
454
  const enable = await confirm("¿Habilitar Engram Cloud para el equipo? (réplica opt-in, requiere un servidor)", false);
220
455
  if (!enable) {
@@ -222,21 +457,100 @@ async function maybeEnableEngramCloud(project, opts) {
222
457
  return { enabled: false };
223
458
  }
224
459
  const server = await ask("URL del servidor de Engram Cloud", "http://127.0.0.1:18080");
460
+ const token = await ask("Token de autenticación (ENGRAM_CLOUD_TOKEN)");
461
+ if (!token) {
462
+ warn("Sin token no se puede configurar Engram Cloud (modo autenticado obligatorio). Continúo con git-sync.");
463
+ return { enabled: false, server };
464
+ }
225
465
  info(`Configurando Engram Cloud → ${server}`);
226
466
  spawnCmd("engram", ["cloud", "config", "--server", server]);
467
+ process.env[CLOUD_TOKEN_ENV] = token;
227
468
  info(`Enrolando el proyecto "${project}"…`);
228
469
  if (spawnCmd("engram", ["cloud", "enroll", project]) === 0) {
470
+ writeTeamCloud(cwd, { enabled: true, server, project, authRequired: true });
471
+ configureCloudAutosync(cwd, opts);
472
+ persistCloudToken(token, opts);
229
473
  ok("Engram Cloud habilitado. Replica con " + c.bold("ozali sync --cloud") + ".");
230
- return { enabled: true, server };
474
+ const dash = cloudDashboardURL(server);
475
+ if (dash) {
476
+ info(`Dashboard: ${c.cyan(dash)}`);
477
+ if (await confirm("¿Abrir el dashboard en el navegador?", false)) openURL(dash);
478
+ }
479
+ return { enabled: true, server, token };
231
480
  }
232
481
  warn("No se pudo enrolar el proyecto en Engram Cloud. Continúo solo con git-sync.");
233
482
  return { enabled: false, server };
234
483
  }
235
484
 
485
+ /**
486
+ * Configura ENGRAM_CLOUD_AUTOSYNC=1 (y ENGRAM_CLOUD_TOKEN) en el bloque env del MCP
487
+ * de Engram del agente, para que la réplica sea automática e invisible.
488
+ */
489
+ function configureCloudAutosync(cwd, opts) {
490
+ const agent = opts.agent || "claude-code";
491
+ const token = process.env[CLOUD_TOKEN_ENV] || "";
492
+ // Claude Code: .claude/settings.json → mcpServers.engram.env
493
+ if (agent === "claude-code" || agent === "both") {
494
+ const p = path.join(cwd, ".claude", "settings.json");
495
+ const cfg = readJSON(p, {});
496
+ cfg.mcpServers = cfg.mcpServers || {};
497
+ cfg.mcpServers.engram = cfg.mcpServers.engram || {};
498
+ cfg.mcpServers.engram.env = cfg.mcpServers.engram.env || {};
499
+ cfg.mcpServers.engram.env[CLOUD_AUTOSYNC_ENV] = "1";
500
+ if (token) cfg.mcpServers.engram.env[CLOUD_TOKEN_ENV] = token;
501
+ writeJSON(p, cfg);
502
+ ok(`Autosync de Engram Cloud configurado en ${c.bold(".claude/settings.json")} (mcpServers.engram.env).`);
503
+ }
504
+ // opencode: opencode.json → mcp.engram.env
505
+ if (agent === "opencode" || agent === "both") {
506
+ const p = path.join(cwd, "opencode.json");
507
+ const cfg = readJSON(p, {});
508
+ cfg.mcp = cfg.mcp || {};
509
+ cfg.mcp.engram = cfg.mcp.engram || {};
510
+ if (cfg.mcp.engram.env === undefined || typeof cfg.mcp.engram.env !== "object") {
511
+ cfg.mcp.engram.env = {};
512
+ } else {
513
+ cfg.mcp.engram.env = { ...cfg.mcp.engram.env };
514
+ }
515
+ cfg.mcp.engram.env[CLOUD_AUTOSYNC_ENV] = "1";
516
+ if (token) cfg.mcp.engram.env[CLOUD_TOKEN_ENV] = token;
517
+ writeJSON(p, cfg);
518
+ ok(`Autosync de Engram Cloud configurado en ${c.bold("opencode.json")} (mcp.engram.env).`);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Persiste el token de Engram Cloud para sesiones futuras.
524
+ * 1) ~/.engram/cloud_token (default de Engram)
525
+ * 2) Avisa al usuario que añada la env var a su shell rc si quiere uso fuera del agente.
526
+ */
527
+ function persistCloudToken(token, opts) {
528
+ const tokenPath = path.join(HOME, ".engram", "cloud_token");
529
+ try {
530
+ ensureDir(path.dirname(tokenPath));
531
+ fs.writeFileSync(tokenPath, token + "\n", { mode: 0o600 });
532
+ ok(`Token guardado en ${c.bold("~/.engram/cloud_token")} (permisos 600).`);
533
+ } catch {
534
+ warn("No pude escribir ~/.engram/cloud_token. Guarda el token manualmente.");
535
+ }
536
+ const shell = process.env.SHELL || "";
537
+ const rc = shell.includes("zsh") ? "~/.zshrc" : shell.includes("bash") ? "~/.bashrc" : null;
538
+ if (rc) {
539
+ info(`Para usar Engram Cloud fuera del agente, añade a ${c.bold(rc)}:`);
540
+ console.log(` ${c.dim(`export ${CLOUD_TOKEN_ENV}=<tu-token>`)}`);
541
+ }
542
+ }
543
+
236
544
  function printEngramManualInstructions(agent) {
237
545
  const plat = process.platform;
238
546
  info("Para activar memoria buscable/acumulativa (modo " + c.bold("hybrid") + "):");
239
- if (plat === "darwin" || plat === "linux") {
547
+ if (plat === "linux") {
548
+ info(" 1. Binario precompilado " + c.dim("(recomendado)") + ": baja " + c.bold("engram_<ver>_linux_<amd64|arm64>.tar.gz") + " de");
549
+ info(" " + c.cyan("https://github.com/Gentleman-Programming/engram/releases"));
550
+ info(" " + c.bold("tar -xzf engram_*_linux_*.tar.gz && mv engram ~/.local/bin/ && chmod +x ~/.local/bin/engram"));
551
+ info(" " + c.dim('(asegúrate que ~/.local/bin esté en tu PATH: export PATH="$HOME/.local/bin:$PATH")'));
552
+ info(" " + c.dim("o, con Go 1.24+: ") + c.bold("go install github.com/Gentleman-Programming/engram/cmd/engram@latest"));
553
+ } else if (plat === "darwin") {
240
554
  info(" 1. " + c.bold("brew install gentleman-programming/tap/engram") + " " + c.dim("(o binario: github.com/Gentleman-Programming/engram/releases)"));
241
555
  } else if (plat === "win32") {
242
556
  info(" 1. " + c.bold("go install github.com/Gentleman-Programming/engram/cmd/engram@latest") + " " + c.dim("(requiere Go 1.24+)"));
@@ -455,9 +769,15 @@ export function doctor(cwd) {
455
769
  const jarvis = detectJarvis(cwd);
456
770
  // jarvis es opt-in (--no-jarvis): informativo, no cuenta como fallo.
457
771
  add("ozali-jarvis", true, jarvis.present ? `configurado (${jarvis.where.join(", ")})` : "no configurado (--no-jarvis)");
458
- const cloudOn = !!(cfg && cfg.cloud && cfg.cloud.enabled);
772
+ const cloudMeta = readTeamCloud(cwd);
773
+ const cloudOn = !!(cfg && cfg.cloud && cfg.cloud.enabled) || !!(cloudMeta && cloudMeta.enrolled);
459
774
  // Cloud es opt-in: "off" es un estado válido (no cuenta como fallo).
460
- add("Engram Cloud", true, cloudOn ? `enrolado → ${cfg.cloud.server || "server por defecto"}` : "off (opt-in, git-sync activo)");
775
+ const cloudDetail = cloudOn
776
+ ? [`enrolado → ${firstNonEmpty(cloudMeta && cloudMeta.server, cfg && cfg.cloud && cfg.cloud.server) || "server por defecto"}`,
777
+ hasCloudToken() ? c.green("token ✓") : c.yellow("sin token"),
778
+ cloudMeta && cloudMeta.dashboard ? c.cyan(cloudMeta.dashboard) : ""].filter(Boolean).join(" · ")
779
+ : "off (opt-in, git-sync activo)";
780
+ add("Engram Cloud", true, cloudDetail);
461
781
  add("Repo de conocimiento", !!(cfg && cfg.knowledgeRepo && exists(cfg.knowledgeRepo)), cfg ? cfg.knowledgeRepo : "sin configurar (ozali init)");
462
782
 
463
783
  // Strict TDD (de la fuente de verdad)
@@ -494,6 +814,42 @@ export function doctor(cwd) {
494
814
  }
495
815
  }
496
816
 
817
+ // Estado detallado de Engram Cloud (si está habilitado).
818
+ if (cloudOn && env.engram.available) {
819
+ const project = (cfg && cfg.project) || projectName(cwd);
820
+ const snap = cloudStatusSnapshot(cwd, project);
821
+ const hasData = snap.syncStatus || snap.upgradeStatus || snap.conflictsStats;
822
+ if (hasData) {
823
+ step("Estado de Engram Cloud");
824
+ if (snap.syncStatus) {
825
+ info("Sync:");
826
+ printIndented(snap.syncStatus);
827
+ // Warnings específicos por reason_code
828
+ const reason = extractReasonCode(snap.syncStatus);
829
+ if (reason === "blocked_unenrolled") warn("El proyecto no está enrolado en el servidor cloud. Corre " + c.bold("ozali init") + " para re-enrolarlo.");
830
+ if (reason === "transport_failed") warn("No se pudo conectar al servidor cloud (transport_failed). Verifica la URL y tu conexión.");
831
+ }
832
+ if (snap.upgradeStatus) {
833
+ info("Upgrade:");
834
+ printIndented(snap.upgradeStatus);
835
+ // Fase 3.2: sugerir upgrade si el estado no es bootstrap_verified
836
+ const upgradeReason = extractReasonCode(snap.upgradeStatus);
837
+ if (upgradeReason && upgradeReason !== "bootstrap_verified") {
838
+ warn(`El proyecto requiere upgrade de cloud (estado: ${upgradeReason}). Corre ${c.bold("ozali cloud upgrade")}.`);
839
+ }
840
+ }
841
+ if (snap.conflictsStats) {
842
+ info("Conflictos:");
843
+ printIndented(snap.conflictsStats);
844
+ // Fase 4.2: advertir conflictos pendientes
845
+ const pendingMatch = snap.conflictsStats.match(/pending\s*[:=]\s*(\d+)/i);
846
+ const pending = pendingMatch ? parseInt(pendingMatch[1], 10) : 0;
847
+ if (pending > 0) warn(`${pending} conflicto(s) de memoria sin juzgar. Usa ${c.bold("ozali audit --conflicts")}.`);
848
+ }
849
+ }
850
+ if (cloudMeta && cloudMeta.dashboard) info(`Dashboard: ${c.cyan(cloudMeta.dashboard)}`);
851
+ }
852
+
497
853
  // Tendencia de uso de tokens (la escribe cdk en cada hito; informativo).
498
854
  const metrics = readJSON(path.join(cwd, ".ozali", "metrics", "token-metrics.json"));
499
855
  if (metrics && Array.isArray(metrics.hits) && metrics.hits.length) {
@@ -528,23 +884,75 @@ function readStrictTdd(cwd, sot) {
528
884
  }
529
885
 
530
886
  // =========================================================== update ==========
531
- export function update(cwd) {
532
- step("ozali update actualizar skill instalada");
887
+ // Lleva una instalación existente al paquete actual: refresca la skill ozali (con sus
888
+ // references), los perfiles de permisos y **crea/refresca ozali-jarvis** (clave para repos
889
+ // inicializados antes de 0.4.0). La skill `cdk` la regenera el AGENTE (no el CLI): se detecta
890
+ // y se guía la regeneración.
891
+ export function update(cwd, opts = {}) {
892
+ step("ozali update — actualizar la instalación al paquete actual");
533
893
  const env = detectAll(cwd);
534
- if (!env.skill.installed) { warn("No hay skill ozali instalada. Corre " + c.bold("ozali init") + " primero."); return 1; }
535
- for (const p of env.skill.paths) {
536
- copyDir(SKILL_SRC, p);
537
- ok(`Actualizada: ${path.relative(cwd, p) || p} → v${pkgVersion()}`);
538
- }
539
894
  const cfgPath = CONFIG_PATH(cwd);
540
895
  const cfg = readJSON(cfgPath);
896
+ if (!env.skill.installed && !cfg) {
897
+ warn("No hay instalación de ozali en esta ruta. Corre " + c.bold("ozali init") + " primero.");
898
+ return 1;
899
+ }
900
+
901
+ // 1) Skill ozali (incluye las references: la base desde la que el agente regenera cdk)
902
+ if (env.skill.installed) {
903
+ for (const p of env.skill.paths) {
904
+ copyDir(SKILL_SRC, p);
905
+ ok(`Skill ozali actualizada: ${path.relative(cwd, p) || p} → v${pkgVersion()}`);
906
+ }
907
+ } else {
908
+ warn("Skill ozali no instalada en esta ruta (corre " + c.bold("ozali init") + " para instalarla).");
909
+ }
910
+
911
+ // Agente/scope: del config; si falta, infiere del entorno.
912
+ const agent = (cfg && cfg.agent) || (env.agents.opencode.present && !env.agents.claudeCode.present ? "opencode"
913
+ : env.agents.claudeCode.present && env.agents.opencode.present ? "both" : "claude-code");
914
+ const scope = (cfg && cfg.scope) || "project";
915
+
916
+ // 2) Perfiles base de permisos (idempotente: recoge defaults nuevos del paquete)
917
+ if (agent === "claude-code" || agent === "both") ensureClaudeCodeProfile(cwd, scope);
918
+ if (agent === "opencode" || agent === "both") ensureOpencodeProfile(cwd);
919
+
920
+ // 3) ozali-jarvis: crea el orquestador en repos previos a 0.4.0 y refresca el resto.
921
+ if (!opts.noJarvis) {
922
+ const proj = projectName(cwd);
923
+ const engPath = ENGRAM_CONFIG_PATH(cwd);
924
+ if (!exists(engPath)) { writeJSON(engPath, { project_name: proj }); ok(`Proyecto de memoria fijado en ${c.bold(".engram/config.json")} (${proj}).`); }
925
+ if (agent === "claude-code" || agent === "both") ensureJarvisClaudeCode(cwd);
926
+ if (agent === "opencode" || agent === "both") ensureJarvisOpencode(cwd);
927
+ }
928
+
929
+ // 4) Skill cdk: la genera el AGENTE (Fase 6), el CLI no la regenera.
930
+ const cdk = detectCdk(cwd);
931
+ if (cdk.installed) {
932
+ step("Skill cdk (generada por el agente)");
933
+ warn("cdk no se actualiza desde el CLI: la regenera tu agente con el contrato nuevo.");
934
+ info("Abre tu agente y vuelve a correr la skill " + c.bold("ozali") + " para regenerar cdk (ozali-jarvis, recall-first §7, telemetría).");
935
+ info("Tus docs por hito (" + c.bold(".ozali/docs/cdk/") + ") y el plan congelado se conservan.");
936
+ }
937
+
938
+ // 5) versión del config
541
939
  if (cfg) { cfg.version = pkgVersion(); cfg.updatedAt = new Date().toISOString(); writeJSON(cfgPath, cfg); }
940
+ ok(`Instalación al día con ozali v${pkgVersion()}.`);
542
941
  return 0;
543
942
  }
544
943
 
944
+ /** ¿Existe la skill cdk (generada por el agente)? */
945
+ function detectCdk(cwd) {
946
+ const paths = [
947
+ path.join(cwd, ".claude", "skills", "cdk", "SKILL.md"),
948
+ path.join(HOME, ".claude", "skills", "cdk", "SKILL.md"),
949
+ ].filter(exists);
950
+ return { installed: paths.length > 0, paths };
951
+ }
952
+
545
953
  // ============================================================= sync ===========
546
954
  export function sync(cwd, opts) {
547
- step(`ozali sync${opts.import ? " --import" : ""} — histórico ↔ repo de conocimiento`);
955
+ step(`ozali sync${opts.import ? " --import" : ""}${opts.cloud ? " --cloud" : ""} — histórico ↔ repo de conocimiento`);
548
956
  const cfg = readJSON(CONFIG_PATH(cwd));
549
957
  if (!cfg || !cfg.knowledgeRepo) { warn("Sin repo de conocimiento configurado. Corre " + c.bold("ozali init") + "."); return 1; }
550
958
  const kRepo = cfg.knowledgeRepo;
@@ -556,11 +964,48 @@ export function sync(cwd, opts) {
556
964
 
557
965
  // Engram Cloud (opt-in): réplica bidireccional adicional al git-sync.
558
966
  if (opts.cloud) {
559
- if (cfg.cloud && cfg.cloud.enabled && tryExec("engram", ["--version"])) {
560
- info(`Replicando con Engram Cloud (proyecto "${project}")…`);
561
- if (spawnCmd("engram", ["sync", "--cloud", "--project", project], { cwd }) === 0) ok("Réplica con Engram Cloud completada.");
562
- else warn("engram sync --cloud no terminó correctamente. Revisa el output de arriba.");
563
- } else if (!(cfg.cloud && cfg.cloud.enabled)) {
967
+ const cloudMeta = readTeamCloud(cwd);
968
+ const cloudEnabled = (cfg.cloud && cfg.cloud.enabled) || (cloudMeta && cloudMeta.enrolled);
969
+ if (cloudEnabled && tryExec("engram", ["--version"])) {
970
+ if (!hasCloudToken() && cloudMeta && cloudMeta.auth_required !== false) {
971
+ warn(`El servidor cloud puede requerir autenticación. Define ${c.bold(CLOUD_TOKEN_ENV)} si falla el sync.`);
972
+ }
973
+ const cloudServer = firstNonEmpty(cloudMeta && cloudMeta.server, cfg.cloud && cfg.cloud.server);
974
+ if (cloudServer && process.env[CLOUD_SERVER_ENV] === undefined) process.env[CLOUD_SERVER_ENV] = cloudServer;
975
+
976
+ if (opts.import) {
977
+ // --- onboarding inverso: pull desde cloud ---
978
+ info(`Recibiendo memoria del equipo desde Engram Cloud (proyecto "${project}")…`);
979
+ const pullOut = tryExec("engram", ["sync", "--cloud", "--import", "--project", project], { cwd });
980
+ if (pullOut !== null) {
981
+ ok("Pull desde Engram Cloud completado.");
982
+ if (pullOut.trim()) printIndented(pullOut);
983
+ // Importar los chunks locales que llegaron vía cloud
984
+ if (spawnCmd("engram", ["sync", "--import"], { cwd }) === 0) ok("Memorias cloud importadas a Engram local.");
985
+ else warn("engram sync --import no terminó correctamente tras el pull cloud.");
986
+ // Verificación
987
+ const verify = tryExec("engram", ["cloud", "status", "--project", project], { cwd });
988
+ if (verify) { info("Estado de cloud:"); printIndented(verify); }
989
+ } else {
990
+ const reason = extractReasonCode(pullOut);
991
+ warn(`engram sync --cloud --import no terminó correctamente${reason ? ` (motivo: ${reason})` : ""}.`);
992
+ if (reason === "blocked_unenrolled") info("El proyecto no está enrolado en el servidor. Corre " + c.bold("ozali init") + " para enrolarlo.");
993
+ }
994
+ } else {
995
+ // --- push a cloud (default) ---
996
+ info(`Replicando con Engram Cloud (proyecto "${project}")…`);
997
+ const out = syncCloudProject(cwd, project);
998
+ if (out !== null) {
999
+ ok("Réplica con Engram Cloud completada.");
1000
+ if (out.trim()) printIndented(out);
1001
+ } else {
1002
+ const reason = extractReasonCode(out);
1003
+ warn(`engram sync --cloud no terminó correctamente${reason ? ` (motivo: ${reason})` : ""}. Revisa el output de arriba.`);
1004
+ if (reason === "blocked_unenrolled") info("El proyecto no está enrolado en el servidor. Corre " + c.bold("ozali init") + " para enrolarlo.");
1005
+ if (reason === "transport_failed") info("No se pudo conectar al servidor cloud. Verifica la URL y tu conexión.");
1006
+ }
1007
+ }
1008
+ } else if (!cloudEnabled) {
564
1009
  warn("--cloud pedido, pero Engram Cloud no está habilitado. Corre " + c.bold("ozali init") + " para habilitarlo, o usa git-sync sin --cloud.");
565
1010
  } else {
566
1011
  warn("--cloud pedido, pero Engram no responde. Omito la réplica cloud.");
@@ -640,7 +1085,7 @@ export async function audit(cwd, opts) {
640
1085
  step("ozali audit — auditoría de memoria (Engram)");
641
1086
  const env = detectAll(cwd);
642
1087
  const cfg = readJSON(CONFIG_PATH(cwd));
643
- const engramCfg = readJSON(path.join(cwd, ".engram", "config.json"));
1088
+ const engramCfg = readJSON(ENGRAM_CONFIG_PATH(cwd));
644
1089
  const project = (engramCfg && engramCfg.project_name) || (cfg && cfg.project) || projectName(cwd);
645
1090
  const hasProjectContext = env.git.isRepo || !!cfg || !!engramCfg;
646
1091
 
@@ -660,6 +1105,34 @@ export async function audit(cwd, opts) {
660
1105
  return auditFromDocs(cwd, project);
661
1106
  }
662
1107
 
1108
+ // Fase 4.1: --dashboard abre el dashboard de Engram Cloud
1109
+ if (opts.dashboard) {
1110
+ const cloudMeta = readTeamCloud(cwd);
1111
+ const dash = (cloudMeta && cloudMeta.dashboard) || (cloudMeta && cloudMeta.server ? cloudDashboardURL(cloudMeta.server) : null);
1112
+ if (!dash) { warn("No hay Engram Cloud configurado. Corre " + c.bold("ozali init") + " primero."); return 1; }
1113
+ info(`Abriendo dashboard: ${c.cyan(dash)}`);
1114
+ openURL(dash);
1115
+ return 0;
1116
+ }
1117
+
1118
+ // Fase 4.1: --conflicts lista/stats conflictos de memoria
1119
+ if (opts.conflicts) {
1120
+ const projArg = scope === "project" ? ["--project", project] : [];
1121
+ if (opts.stats) {
1122
+ step(`Estadísticas de conflictos${scope === "project" ? ` — ${project}` : ""}`);
1123
+ const out = tryExec("engram", ["conflicts", "stats", ...projArg], { cwd });
1124
+ if (out) printIndented(out);
1125
+ else warn("No se pudieron obtener estadísticas de conflictos.");
1126
+ } else {
1127
+ const statusFlag = opts.judged ? ["--status", "judged"] : [];
1128
+ step(`Conflictos${opts.judged ? " juzgados" : " pendientes"}${scope === "project" ? ` — ${project}` : ""}`);
1129
+ const out = tryExec("engram", ["conflicts", "list", ...statusFlag, ...projArg], { cwd });
1130
+ if (out) printIndented(out);
1131
+ else warn("No se pudieron listar conflictos.");
1132
+ }
1133
+ return 0;
1134
+ }
1135
+
663
1136
  // Navegador interactivo.
664
1137
  if (opts.tui) {
665
1138
  info("Abriendo el navegador interactivo de Engram (engram tui)…");
@@ -705,3 +1178,100 @@ function auditFromDocs(cwd, project) {
705
1178
  info("Instala Engram para auditoría buscable/acumulativa (" + c.bold("ozali doctor") + ").");
706
1179
  return 0;
707
1180
  }
1181
+
1182
+ // ============================================================= cloud ===========
1183
+ export async function cloud(cwd, opts) {
1184
+ const sub = opts._[1] || "status";
1185
+ const cfg = readJSON(CONFIG_PATH(cwd));
1186
+ const cloudMeta = readTeamCloud(cwd);
1187
+ const project = (cfg && cfg.project) || projectName(cwd);
1188
+ const cloudServer = firstNonEmpty(cloudMeta && cloudMeta.server, cfg && cfg.cloud && cfg.cloud.server);
1189
+
1190
+ switch (sub) {
1191
+ case "status": {
1192
+ step(`ozali cloud status — proyecto "${project}"`);
1193
+ if (!tryExec("engram", ["--version"])) { warn("Engram no responde."); return 1; }
1194
+ if (cloudServer && process.env[CLOUD_SERVER_ENV] === undefined) process.env[CLOUD_SERVER_ENV] = cloudServer;
1195
+ const status = tryExec("engram", ["cloud", "status", "--project", project], { cwd });
1196
+ if (status) { info("Estado:"); printIndented(status); }
1197
+ else { warn("No se pudo obtener el estado de cloud."); }
1198
+ const upgrade = tryExec("engram", ["cloud", "upgrade", "status", "--project", project], { cwd });
1199
+ if (upgrade) { info("Upgrade:"); printIndented(upgrade); }
1200
+ return 0;
1201
+ }
1202
+
1203
+ case "upgrade": {
1204
+ step(`ozali cloud upgrade — proyecto "${project}"`);
1205
+ if (!tryExec("engram", ["--version"])) { warn("Engram no responde."); return 1; }
1206
+ if (cloudServer && process.env[CLOUD_SERVER_ENV] === undefined) process.env[CLOUD_SERVER_ENV] = cloudServer;
1207
+ // 1) doctor (checkpoint)
1208
+ info("Paso 1/3: Verificando estado actual…");
1209
+ const status = tryExec("engram", ["cloud", "status", "--project", project], { cwd });
1210
+ if (status) printIndented(status);
1211
+ // 2) repair --dry-run
1212
+ info("Paso 2/3: Simulando repair (dry-run)…");
1213
+ const dryRun = tryExec("engram", ["cloud", "upgrade", "repair", "--dry-run", "--project", project], { cwd });
1214
+ if (dryRun) printIndented(dryRun);
1215
+ const doApply = await confirm("¿Aplicar el repair ahora?", true);
1216
+ if (!doApply) { info("Upgrade cancelado. Puedes aplicarlo después con " + c.bold("ozali cloud repair") + "."); return 0; }
1217
+ // 3) repair --apply + bootstrap
1218
+ info("Paso 3/3: Aplicando repair…");
1219
+ const repairOut = tryExec("engram", ["cloud", "upgrade", "repair", "--apply", "--project", project], { cwd });
1220
+ if (repairOut !== null) { ok("Repair aplicado."); if (repairOut.trim()) printIndented(repairOut); }
1221
+ else { warn("Repair falló. Revisa el output de arriba."); return 1; }
1222
+ const boot = tryExec("engram", ["cloud", "upgrade", "bootstrap", "--project", project], { cwd });
1223
+ if (boot !== null) { ok("Bootstrap completado."); if (boot.trim()) printIndented(boot); }
1224
+ else warn("Bootstrap falló. Corre " + c.bold("ozali cloud status") + " para ver el estado.");
1225
+ return 0;
1226
+ }
1227
+
1228
+ case "repair": {
1229
+ step(`ozali cloud repair — proyecto "${project}"`);
1230
+ if (!tryExec("engram", ["--version"])) { warn("Engram no responde."); return 1; }
1231
+ if (cloudServer && process.env[CLOUD_SERVER_ENV] === undefined) process.env[CLOUD_SERVER_ENV] = cloudServer;
1232
+ const out = tryExec("engram", ["cloud", "upgrade", "repair", "--apply", "--project", project], { cwd });
1233
+ if (out !== null) { ok("Repair aplicado."); if (out.trim()) printIndented(out); return 0; }
1234
+ warn("Repair falló. Revisa el output de arriba.");
1235
+ return 1;
1236
+ }
1237
+
1238
+ case "dashboard": {
1239
+ const dash = cloudMeta && cloudMeta.dashboard ? cloudMeta.dashboard : (cloudServer ? cloudDashboardURL(cloudServer) : null);
1240
+ if (!dash) { warn("No hay servidor cloud configurado. Corre " + c.bold("ozali init") + " primero."); return 1; }
1241
+ info(`Abriendo dashboard: ${c.cyan(dash)}`);
1242
+ openURL(dash);
1243
+ return 0;
1244
+ }
1245
+
1246
+ case "config": {
1247
+ step("ozali cloud config");
1248
+ if (cloudMeta && cloudMeta.enrolled) {
1249
+ info(`Servidor: ${c.bold(cloudMeta.server || "no definido")}`);
1250
+ info(`Proyecto: ${c.bold(cloudMeta.project || project)}`);
1251
+ info(`Token: ${hasCloudToken() ? c.green("✓ configurado") : c.yellow("✗ no configurado")}`);
1252
+ info(`Dashboard: ${cloudMeta.dashboard ? c.cyan(cloudMeta.dashboard) : "no disponible"}`);
1253
+ info(`Autosync: ${c.bold(CLOUD_AUTOSYNC_ENV)}=${process.env[CLOUD_AUTOSYNC_ENV] || "no definido"}`);
1254
+ const reconfig = await confirm("¿Reconfigurar el servidor?", false);
1255
+ if (!reconfig) return 0;
1256
+ }
1257
+ const server = await ask("URL del servidor de Engram Cloud", cloudMeta && cloudMeta.server || "http://127.0.0.1:18080");
1258
+ const token = await ask("Token de autenticación");
1259
+ if (!token) { warn("Sin token no se puede configurar (modo autenticado obligatorio)."); return 1; }
1260
+ spawnCmd("engram", ["cloud", "config", "--server", server]);
1261
+ process.env[CLOUD_TOKEN_ENV] = token;
1262
+ if (spawnCmd("engram", ["cloud", "enroll", project]) === 0) {
1263
+ writeTeamCloud(cwd, { enabled: true, server, project, authRequired: true });
1264
+ configureCloudAutosync(cwd, opts);
1265
+ persistCloudToken(token, opts);
1266
+ ok("Engram Cloud reconfigurado.");
1267
+ return 0;
1268
+ }
1269
+ warn("No se pudo enrolar el proyecto. Verifica el servidor y el token.");
1270
+ return 1;
1271
+ }
1272
+
1273
+ default:
1274
+ err(`Subcomando desconocido "${sub}". Usa: status | upgrade | repair | dashboard | config`);
1275
+ return 1;
1276
+ }
1277
+ }