@jpkme/diff-review 0.1.5 → 0.1.7

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.
Files changed (85) hide show
  1. package/README.md +33 -2
  2. package/bin/diff-review.mjs +230 -18
  3. package/lib/agents.mjs +304 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +213 -0
  6. package/lib/check-report.test.mjs +46 -0
  7. package/lib/fingerprint.mjs +24 -0
  8. package/lib/git.mjs +2 -19
  9. package/lib/output-name.mjs +1 -1
  10. package/lib/prompt.mjs +22 -9
  11. package/lib/wait-output.mjs +43 -17
  12. package/lib/wait-output.test.mjs +67 -0
  13. package/package.json +1 -1
  14. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +1 -0
  15. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  16. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  17. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +2 -0
  18. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  19. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  20. package/web/build/client/_app/immutable/chunks/{CdHZDAAo.js → D8sAxZVo.js} +38 -30
  21. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  22. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  23. package/web/build/client/_app/immutable/entry/{app.BRzz8J51.js → app.Dl9OTE-n.js} +2 -2
  24. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  25. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  26. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +1 -0
  27. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +1 -0
  28. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  29. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +1 -0
  30. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  31. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  32. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +66 -0
  33. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  34. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  35. package/web/build/client/_app/immutable/nodes/{3.ByTmpJQZ.js → 3.C-USexyX.js} +2 -2
  36. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  37. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  38. package/web/build/client/_app/version.json +1 -1
  39. package/web/build/client/_app/version.json.br +0 -0
  40. package/web/build/client/_app/version.json.gz +0 -0
  41. package/web/build/handler.js +3 -3
  42. package/web/build/index.js +3 -3
  43. package/web/build/server/chunks/1-0X6Jp9nH.js +7 -0
  44. package/web/build/server/chunks/1-0X6Jp9nH.js.map +1 -0
  45. package/web/build/server/chunks/2-HlcVALg6.js +7 -0
  46. package/web/build/server/chunks/2-HlcVALg6.js.map +1 -0
  47. package/web/build/server/chunks/{3-DZ5PBhOc.js → 3-BN1eS7L2.js} +2 -2
  48. package/web/build/server/chunks/{3-DZ5PBhOc.js.map → 3-BN1eS7L2.js.map} +1 -1
  49. package/web/build/server/chunks/{handler-DD4vP3bU.js → handler-cq3QoEzW.js} +3 -3
  50. package/web/build/server/chunks/{handler-DD4vP3bU.js.map → handler-cq3QoEzW.js.map} +1 -1
  51. package/web/build/server/chunks/{index.js-IhFPHwmp.js → index.js-D4qkHe3r.js} +2 -2
  52. package/web/build/server/chunks/{index.js-IhFPHwmp.js.map → index.js-D4qkHe3r.js.map} +1 -1
  53. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js → manifest.js-DQHMKyJh.js} +6 -6
  54. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js.map → manifest.js-DQHMKyJh.js.map} +1 -1
  55. package/web/build/server/chunks/{remote-1g4ayag-BRL-uaKE.js → remote-1g4ayag-B8KZPTkn.js} +154 -149
  56. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +1 -0
  57. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  58. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  59. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  60. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js +0 -1
  61. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +0 -2
  62. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
  63. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
  64. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
  65. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +0 -2
  66. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
  67. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
  69. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
  70. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +0 -1
  71. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
  72. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
  73. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +0 -1
  74. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +0 -1
  75. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
  76. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +0 -66
  77. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
  78. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
  79. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
  80. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.gz +0 -0
  81. package/web/build/server/chunks/1-qrn24yN5.js +0 -7
  82. package/web/build/server/chunks/1-qrn24yN5.js.map +0 -1
  83. package/web/build/server/chunks/2-C7_Loqyn.js +0 -7
  84. package/web/build/server/chunks/2-C7_Loqyn.js.map +0 -1
  85. package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.js.map +0 -1
package/README.md CHANGED
@@ -32,13 +32,44 @@ Desde un **repo git**, el default es terminal-first:
32
32
 
33
33
  Si pasás `--branch` y `--base` juntos, saltea los selects. Si quedan iguales (diff vacío), abre la UI.
34
34
 
35
+ Si ya hay un JSON válido para esos mismos commits, pregunta si abrir el existente o generarlo de nuevo; en ese
36
+ caso el archivo viejo no cuenta hasta que el agente lo reescriba. Sin terminal interactiva no pregunta: con
37
+ `--agent` lo genera de nuevo y sin `--agent` abre el existente.
38
+
39
+ ### Lanzar el agente (`--agent`)
40
+
41
+ Si hay agentes instalados, después de branch y base un solo select lista copiar el prompt al portapapeles (primera
42
+ opción) y cada agente detectado. Con `--agent` saltea esa lista y va directo a elegir agente y modelo:
43
+
44
+ ```bash
45
+ diff-review --agent
46
+ ```
47
+
48
+ Al enviarlo a un agente, en vez de copiar el prompt pregunta el modelo (con `--agent`, primero el agente):
49
+
50
+ 1. **Agente**: los instalados que encuentra (Claude Code, Codex, Cursor CLI, GitHub Copilot CLI, Gemini CLI). Si ya lo elegiste en la lista anterior, este paso no se repite. Se buscan por nombre en el `PATH` y en algunas rutas fijas: en Windows, el Claude Code que trae la app de
51
+ escritorio (`%APPDATA%\Claude\claude-code\<versión>\…\claude.exe`) y `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd`;
52
+ en Linux/macOS, `~/.local/bin/claude`, `~/.claude/local/claude` y `~/.local/bin/cursor-agent`.
53
+ 2. **Modelo**: arranca en el default del agente (el configurado en `~/.claude/settings.json` /
54
+ `ANTHROPIC_MODEL`, `~/.codex/config.toml`, `~/.cursor/cli-config.json`, `~/.copilot/settings.json` o
55
+ `~/.gemini/settings.json`; si no hay, no se pasa `--model`). También lista los conocidos (para Claude, los
56
+ alias `opus` / `sonnet` / `haiku`; para Copilot, `auto`) y "Otro…" para escribir un id.
57
+
58
+ El prompt viaja por stdin y la salida del agente se ve en la terminal. Claude Code y Copilot corren con permisos
59
+ acotados: leer, `git`, el `validate` y escribir solo el JSON del review (Copilot no puede acotar por argumentos,
60
+ así que permite `npx` en general). Cursor corre con `--force` (no tiene
61
+ flag para pasar una allowlist y sin él bloquearía el `validate`) y Gemini con `--yolo`. Si no hay agentes
62
+ instalados, sigue con el portapapeles. Sin terminal interactiva usa el primer agente con su modelo por defecto.
63
+
64
+ El agente tiene que estar logueado (p. ej. `claude /login` una vez).
65
+
35
66
  Para abrir la UI primero (sin esperar el JSON):
36
67
 
37
68
  ```bash
38
69
  diff-review --ui
39
70
  ```
40
71
 
41
- Opciones: `--port`, `--host`, `--base`, `--branch`, `--no-open`.
72
+ Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--no-open`.
42
73
 
43
74
  Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
44
75
 
@@ -55,7 +86,7 @@ npm start # mismo que el bin global
55
86
 
56
87
  1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` / `diff-review --ui` desde el repo.
57
88
  2. Copiá el prompt (o el comando de skill `/diff-review`).
58
- 3. Corrélo en tu agente; que escriba `diff-rev_{branchTip}_{baseTip}.json` (también acepta nombres legacy).
89
+ 3. Corrélo en tu agente; que escriba `diff-rev_{branchTip}_{baseTip}.json` y lo valide con `npx --yes @jpkme/diff-review validate <archivo>` (también acepta nombres legacy).
59
90
  4. Arrastrá ese JSON a la dropzone (o dejá que el CLI lo importe solo). La UI hidrata los diffs con git.
60
91
 
61
92
  "Ver ejemplo" carga un reporte de muestra sin tocar un repo.
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * diff-review --port 5191
8
8
  * diff-review --branch x --base y → saltea selects de Clack
9
+ * diff-review --agent → elegir agente + modelo y lanzarlo con el prompt (sin portapapeles)
9
10
  * diff-review --no-open
10
11
  */
11
12
 
@@ -15,12 +16,14 @@ import { createServer } from 'node:net';
15
16
  import { dirname, join } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import * as p from '@clack/prompts';
19
+ import { AGENTS, detectAgents, modelChoices, OTHER_MODEL, runAgent } from '../lib/agents.mjs';
18
20
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
19
21
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
20
22
  import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
21
23
  import { makeOutputFilename } from '../lib/output-name.mjs';
22
24
  import { buildPrompt } from '../lib/prompt.mjs';
23
- import { waitForReviewOutput } from '../lib/wait-output.mjs';
25
+ import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mjs';
26
+ import { runValidateCli } from '../lib/check-report.mjs';
24
27
 
25
28
  const here = dirname(fileURLToPath(import.meta.url));
26
29
  const packageRoot = join(here, '..');
@@ -50,18 +53,24 @@ function printHelp() {
50
53
 
51
54
  Usage:
52
55
  diff-review [options]
56
+ diff-review validate <file.json>
53
57
 
54
58
  Por defecto (desde un repo git): elegís branch/base en la terminal (Clack),
55
- copia el prompt, espera el JSON y abre el viewer.
59
+ copia el prompt, espera el JSON y abre el viewer. Si hay agentes instalados,
60
+ un solo select lista el portapapeles y cada agente detectado.
61
+ \`validate\` revisa el JSON y, si falla, dice cómo seguir (exit 0 = OK).
56
62
  Si pasás --branch y --base juntos, saltea los selects.
63
+ Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
64
+ y diff-review lo lanza con el prompt.
57
65
  Con --ui: abre la UI de inmediato (web-first).
58
66
 
59
67
  Options:
60
- -p, --port <n> Puerto (default ${DEFAULT_PORT})
68
+ -p, --port <n> Puerto (default ${DEFAULT_PORT}; si está ocupado, el siguiente libre)
61
69
  --host <h> Host (default ${DEFAULT_HOST})
62
70
  --ui Abrir la UI primero (sin esperar JSON)
63
71
  --base <b> Base del merge-base (salta el select de base)
64
72
  --branch <b> Branch a revisar (salta el select de branch)
73
+ --agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
65
74
  --no-open No abrir el navegador
66
75
  -h, --help Esta ayuda
67
76
 
@@ -76,6 +85,7 @@ function parseArgs(argv) {
76
85
  open: true,
77
86
  help: false,
78
87
  ui: false,
88
+ agent: false,
79
89
  base: '',
80
90
  branch: ''
81
91
  };
@@ -84,6 +94,7 @@ function parseArgs(argv) {
84
94
  if (a === '-h' || a === '--help') opts.help = true;
85
95
  else if (a === '--no-open') opts.open = false;
86
96
  else if (a === '--ui') opts.ui = true;
97
+ else if (a === '--agent') opts.agent = true;
87
98
  else if (a === '--host') opts.host = argv[++i] || opts.host;
88
99
  else if (a === '--base') opts.base = argv[++i] || opts.base;
89
100
  else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
@@ -133,6 +144,18 @@ function canListen(host, port) {
133
144
  });
134
145
  }
135
146
 
147
+ const PORT_SCAN = 50;
148
+
149
+ /** Primer puerto libre desde `start`, o `null` si ninguno de los siguientes cabe. */
150
+ async function findFreePort(host, start) {
151
+ const first = Math.max(1, Math.floor(start));
152
+ const last = Math.min(65535, first + PORT_SCAN - 1);
153
+ for (let port = first; port <= last; port++) {
154
+ if (await canListen(host, port)) return port;
155
+ }
156
+ return null;
157
+ }
158
+
136
159
  /**
137
160
  * @returns {{
138
161
  * repo: string,
@@ -167,6 +190,11 @@ function branchOptions(listed) {
167
190
  }));
168
191
  }
169
192
 
193
+ /** Selects solo con terminal interactiva. (`p.isTTY` de Clack es una función, no un booleano.) */
194
+ function isInteractive() {
195
+ return p.isTTY(process.stdin) && p.isTTY(process.stdout);
196
+ }
197
+
170
198
  function ensureValue(value, label) {
171
199
  if (p.isCancel(value)) {
172
200
  p.cancel('Cancelado.');
@@ -193,7 +221,7 @@ async function resolveBranchBase(launch, opts) {
193
221
  return { branch: flagBranch, base: flagBase, prompted: false };
194
222
  }
195
223
 
196
- if (!p.isTTY) {
224
+ if (!isInteractive()) {
197
225
  const branch = flagBranch || defaultBranch(launch.listed);
198
226
  const base = flagBase || defaultBase(launch.listed, branch);
199
227
  return { branch, base, prompted: false };
@@ -278,6 +306,142 @@ function copyPromptOrWarn(prompt) {
278
306
  return false;
279
307
  }
280
308
 
309
+ /**
310
+ * Modelo del agente: select con TTY; sin TTY, el modelo por defecto (string vacío).
311
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
312
+ */
313
+ async function pickModel(agent) {
314
+ if (!isInteractive()) return '';
315
+
316
+ const { options, initialValue } = modelChoices(agent.def);
317
+ const choice = await p.select({ message: 'Modelo', options, initialValue });
318
+ if (p.isCancel(choice)) {
319
+ p.cancel('Cancelado.');
320
+ process.exit(0);
321
+ }
322
+ if (choice !== OTHER_MODEL) return /** @type {string} */ (choice);
323
+
324
+ const typed = ensureValue(
325
+ await p.text({
326
+ message: 'Id del modelo',
327
+ validate: (v) => (String(v ?? '').trim() ? undefined : 'Escribí el id del modelo.')
328
+ }),
329
+ 'modelo'
330
+ );
331
+ return typed.trim();
332
+ }
333
+
334
+ /**
335
+ * Agente y modelo: selects con TTY; sin TTY, el primer agente detectado con su modelo por defecto.
336
+ * Si `preset` ya viene elegido, solo pregunta el modelo.
337
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
338
+ * @param {import('../lib/agents.mjs').DetectedAgent} [preset]
339
+ */
340
+ async function pickAgentAndModel(detected, preset) {
341
+ if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
342
+
343
+ let agent = preset;
344
+ if (!agent) {
345
+ const id = ensureValue(
346
+ await p.select({
347
+ message: 'Agente',
348
+ options: detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path })),
349
+ initialValue: detected[0].def.id
350
+ }),
351
+ 'agente'
352
+ );
353
+ agent = detected.find((a) => a.def.id === id) ?? detected[0];
354
+ }
355
+
356
+ return { agent, model: await pickModel(agent) };
357
+ }
358
+
359
+ /**
360
+ * Portapapeles primero y después cada agente detectado.
361
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
362
+ * @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
363
+ */
364
+ async function pickDelivery(detected) {
365
+ const choice = ensureValue(
366
+ await p.select({
367
+ message: '¿Cómo generás el review?',
368
+ options: [
369
+ { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' },
370
+ ...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path }))
371
+ ],
372
+ initialValue: 'clipboard'
373
+ }),
374
+ 'modo'
375
+ );
376
+ if (choice === 'clipboard') return null;
377
+ return detected.find((a) => a.def.id === choice) ?? detected[0];
378
+ }
379
+
380
+ /**
381
+ * Ya hay un review válido para estos commits: pregunta si abrirlo o generarlo de nuevo.
382
+ * @param {{ path: string }} existing
383
+ * @returns {Promise<{ path: string } | null>} el existente, o `null` para generar de nuevo
384
+ */
385
+ async function reuseExistingReview(existing, outputFilename) {
386
+ const choice = ensureValue(
387
+ await p.select({
388
+ message: `Ya hay un review válido para estos commits (${outputFilename})`,
389
+ options: [
390
+ { value: 'open', label: 'Abrir el existente' },
391
+ { value: 'regenerate', label: 'Generar de nuevo', hint: 'el agente lo reemplaza' }
392
+ ],
393
+ initialValue: 'open'
394
+ }),
395
+ 'review'
396
+ );
397
+ if (choice !== 'open') return null;
398
+ p.outro('Abro el review existente.');
399
+ return existing;
400
+ }
401
+
402
+ /**
403
+ * Lanza el agente y espera el JSON. Devuelve el reporte válido; si el agente termina sin dejarlo, sale con error.
404
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
405
+ * @param {{ repo: string, prompt: string, model: string, outputFilename: string, useClack: boolean }} run
406
+ */
407
+ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
408
+ const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
409
+ const msg = `Lanzando ${what}. Espera ${outputFilename} en la raíz del repo; Ctrl+C cancela.`;
410
+ if (useClack) p.outro(msg);
411
+ else console.log(msg);
412
+
413
+ // Solo cuenta lo que escriba este agente: un JSON anterior de los mismos commits no es el resultado.
414
+ const since = Date.now();
415
+ const { exited } = runAgent(agent, { cwd: repo, prompt, model, outputFilename });
416
+ const waiting = waitForReviewOutput(repo, outputFilename, {
417
+ since,
418
+ onInvalid: (errors) => {
419
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). El agente puede corregirlo…`);
420
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
421
+ }
422
+ });
423
+
424
+ let code;
425
+ try {
426
+ code = await exited;
427
+ } catch (err) {
428
+ console.error(`No se pudo lanzar ${agent.path}: ${err instanceof Error ? err.message : err}`);
429
+ process.exit(1);
430
+ }
431
+ // El watcher puede ir un tick atrás de la última escritura del agente.
432
+ const result = await Promise.race([waiting, new Promise((r) => setTimeout(() => r(null), 2000))]);
433
+ if (!result) {
434
+ console.error(
435
+ code === 0
436
+ ? `${agent.def.label} terminó sin dejar un ${outputFilename} válido.`
437
+ : `${agent.def.label} terminó con código ${code} sin dejar un ${outputFilename} válido.`
438
+ );
439
+ process.exit(1);
440
+ }
441
+ if (code !== 0) console.warn(`${agent.def.label} terminó con código ${code}, pero el JSON es válido.`);
442
+ return /** @type {{ path: string }} */ (result);
443
+ }
444
+
281
445
  function explainWaitingForAgent(outputFilename, { copied, useClack }) {
282
446
  const copyMsg = copied
283
447
  ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
@@ -306,12 +470,17 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
306
470
  process.exit(1);
307
471
  }
308
472
 
309
- if (!(await canListen(cfg.host, cfg.port))) {
310
- console.error(`No se pudo usar ${cfg.host}:${cfg.port} (¿ocupado?). Probá --port otro.`);
473
+ const port = await findFreePort(cfg.host, cfg.port);
474
+ if (port == null) {
475
+ const last = Math.min(65535, cfg.port + PORT_SCAN - 1);
476
+ console.error(`No encontré un puerto libre entre ${cfg.port} y ${last} en ${cfg.host}.`);
311
477
  process.exit(1);
312
478
  }
479
+ if (port !== cfg.port) {
480
+ console.log(`Puerto ${cfg.port} ocupado; la UI usa ${port}.`);
481
+ }
313
482
 
314
- const origin = `http://${cfg.host}:${cfg.port}`;
483
+ const origin = `http://${cfg.host}:${port}`;
315
484
  const openUrl = cfg.openPath ? `${origin}${cfg.openPath}` : origin;
316
485
  const nodeBin = resolveNodeBin();
317
486
  const child = spawn(nodeBin, [entry], {
@@ -319,7 +488,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
319
488
  env: {
320
489
  ...process.env,
321
490
  HOST: cfg.host,
322
- PORT: String(cfg.port),
491
+ PORT: String(port),
323
492
  ORIGIN: origin,
324
493
  DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
325
494
  ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
@@ -398,12 +567,19 @@ async function runUiMode(opts, launch, launchCwd) {
398
567
  port: opts.port,
399
568
  open: opts.open,
400
569
  launchCwd,
401
- openPath: launch ? `/?notify=prompt&out=${encodeURIComponent(outputFilename)}` : '/'
570
+ openPath: launch
571
+ ? `/?watch=1&notify=prompt&out=${encodeURIComponent(outputFilename)}`
572
+ : '/'
402
573
  });
403
574
  }
404
575
 
405
576
  async function main() {
406
- const opts = parseArgs(process.argv.slice(2));
577
+ const rawArgv = process.argv.slice(2);
578
+ if (rawArgv[0] === 'validate') {
579
+ process.exit(runValidateCli(rawArgv.slice(1)));
580
+ }
581
+
582
+ const opts = parseArgs(rawArgv);
407
583
  if (opts.help) {
408
584
  printHelp();
409
585
  process.exit(0);
@@ -461,15 +637,51 @@ async function main() {
461
637
  { outputFilename }
462
638
  );
463
639
 
464
- const copied = copyPromptOrWarn(prompt);
465
- explainWaitingForAgent(outputFilename, { copied, useClack: prompted });
640
+ // Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
641
+ const detected = opts.agent || isInteractive() ? detectAgents() : [];
642
+ if (opts.agent && !detected.length) {
643
+ const names = AGENTS.map((a) => a.bins[0]).join(', ');
644
+ const warn = `No encontré agentes instalados (${names}). Sigo copiando el prompt.`;
645
+ if (prompted) p.log.warn(warn);
646
+ else console.warn(warn);
647
+ }
466
648
 
467
- const result = await waitForReviewOutput(launch.repo, outputFilename, {
468
- onInvalid: (errors) => {
469
- console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
470
- for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
471
- }
472
- });
649
+ // Hay selects (agente/modelo, o abrir vs. regenerar un review existente) solo con terminal interactiva.
650
+ const existing = isInteractive() ? existingReviewOutput(launch.repo, outputFilename) : null;
651
+ const useClack = prompted || (isInteractive() && (detected.length > 0 || existing != null));
652
+ // Con flags de branch/base no hubo intro, pero estos selects abren sesión de Clack igual.
653
+ if (useClack && !prompted) p.intro('diff-review');
654
+
655
+ let result = existing ? await reuseExistingReview(existing, outputFilename) : null;
656
+ /** @type {import('../lib/agents.mjs').DetectedAgent | null} */
657
+ let chosen = null;
658
+ if (!result && detected.length > 0 && !opts.agent && isInteractive()) {
659
+ chosen = await pickDelivery(detected);
660
+ }
661
+ const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
662
+ if (useAgent) {
663
+ const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined);
664
+ result = await runAgentAndWait(agent, {
665
+ repo: launch.repo,
666
+ prompt,
667
+ model,
668
+ outputFilename,
669
+ useClack
670
+ });
671
+ } else if (!result) {
672
+ // Si eligió generar de nuevo, el JSON existente no cuenta hasta que el agente lo reescriba.
673
+ const since = existing ? Date.now() : undefined;
674
+ const copied = copyPromptOrWarn(prompt);
675
+ explainWaitingForAgent(outputFilename, { copied, useClack });
676
+
677
+ result = await waitForReviewOutput(launch.repo, outputFilename, {
678
+ since,
679
+ onInvalid: (errors) => {
680
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
681
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
682
+ }
683
+ });
684
+ }
473
685
 
474
686
  console.log('JSON OK. Abriendo la UI web…');
475
687
  await startServer({