@jpkme/diff-review 0.1.16 → 0.1.18

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 (154) hide show
  1. package/README.md +22 -14
  2. package/bin/diff-review.mjs +220 -29
  3. package/lib/acp.mjs +86 -10
  4. package/lib/agent-login.mjs +848 -36
  5. package/lib/agent-login.test.mjs +538 -0
  6. package/lib/agents.mjs +0 -39
  7. package/lib/agents.test.mjs +17 -24
  8. package/lib/extract-cache.test.mjs +162 -162
  9. package/lib/git.mjs +25 -9
  10. package/lib/model-label.mjs +20 -4
  11. package/lib/model-label.test.mjs +15 -2
  12. package/lib/prompt.mjs +12 -10
  13. package/lib/prompt.test.mjs +12 -0
  14. package/lib/review-meta.mjs +11 -3
  15. package/lib/review-meta.test.mjs +19 -2
  16. package/lib/review-prep.mjs +63 -63
  17. package/lib/validate.mjs +1 -1
  18. package/lib/wait-output.test.mjs +1 -1
  19. package/package.json +2 -2
  20. package/web/build/client/_app/immutable/assets/0.DdOl5Uve.css +1 -0
  21. package/web/build/client/_app/immutable/assets/0.DdOl5Uve.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/0.DdOl5Uve.css.gz +0 -0
  23. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css +1 -0
  24. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css.br +0 -0
  25. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css.gz +0 -0
  26. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css +1 -0
  27. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css.br +0 -0
  28. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css.gz +0 -0
  29. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css +1 -0
  30. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css.br +0 -0
  31. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css.gz +0 -0
  32. package/web/build/client/_app/immutable/chunks/{cYiFfRa3.js → -cMMFbSG.js} +1 -1
  33. package/web/build/client/_app/immutable/chunks/-cMMFbSG.js.br +2 -0
  34. package/web/build/client/_app/immutable/chunks/-cMMFbSG.js.gz +0 -0
  35. package/web/build/client/_app/immutable/chunks/0vBZPdKl.js +215 -0
  36. package/web/build/client/_app/immutable/chunks/0vBZPdKl.js.br +0 -0
  37. package/web/build/client/_app/immutable/chunks/0vBZPdKl.js.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js +1 -0
  39. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/{DHO7NTh1.js → BMoYpzpA.js} +2 -2
  42. package/web/build/client/_app/immutable/chunks/BMoYpzpA.js.br +0 -0
  43. package/web/build/client/_app/immutable/chunks/BMoYpzpA.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/{CXhIC34H.js → DA0YnpGR.js} +3 -3
  45. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.gz +0 -0
  47. package/web/build/client/_app/immutable/entry/app.CT6P0WMK.js +2 -0
  48. package/web/build/client/_app/immutable/entry/app.CT6P0WMK.js.br +0 -0
  49. package/web/build/client/_app/immutable/entry/app.CT6P0WMK.js.gz +0 -0
  50. package/web/build/client/_app/immutable/entry/start.U0TDB-jm.js +1 -0
  51. package/web/build/client/_app/immutable/entry/start.U0TDB-jm.js.br +0 -0
  52. package/web/build/client/_app/immutable/entry/start.U0TDB-jm.js.gz +0 -0
  53. package/web/build/client/_app/immutable/nodes/0.ii5weUjH.js +1 -0
  54. package/web/build/client/_app/immutable/nodes/0.ii5weUjH.js.br +0 -0
  55. package/web/build/client/_app/immutable/nodes/0.ii5weUjH.js.gz +0 -0
  56. package/web/build/client/_app/immutable/nodes/1.CM10dYkJ.js +1 -0
  57. package/web/build/client/_app/immutable/nodes/1.CM10dYkJ.js.br +2 -0
  58. package/web/build/client/_app/immutable/nodes/1.CM10dYkJ.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/2.B1LHk3LQ.js +12 -0
  60. package/web/build/client/_app/immutable/nodes/2.B1LHk3LQ.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/2.B1LHk3LQ.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/3.BQOdjQBQ.js +340 -0
  63. package/web/build/client/_app/immutable/nodes/3.BQOdjQBQ.js.br +0 -0
  64. package/web/build/client/_app/immutable/nodes/3.BQOdjQBQ.js.gz +0 -0
  65. package/web/build/client/_app/version.json +1 -1
  66. package/web/build/client/_app/version.json.br +0 -0
  67. package/web/build/client/_app/version.json.gz +0 -0
  68. package/web/build/handler.js +3 -3
  69. package/web/build/index.js +3 -3
  70. package/web/build/server/chunks/0-BUdytb9b.js +11 -0
  71. package/web/build/server/chunks/{0-D7NSxn1-.js.map → 0-BUdytb9b.js.map} +1 -1
  72. package/web/build/server/chunks/1-DRokSBkY.js +7 -0
  73. package/web/build/server/chunks/{1-DboYz8Fw.js.map → 1-DRokSBkY.js.map} +1 -1
  74. package/web/build/server/chunks/2-BxyzaJTR.js +7 -0
  75. package/web/build/server/chunks/2-BxyzaJTR.js.map +1 -0
  76. package/web/build/server/chunks/3-D8o0VWkj.js +7 -0
  77. package/web/build/server/chunks/{3-DgJ_LmTL.js.map → 3-D8o0VWkj.js.map} +1 -1
  78. package/web/build/server/chunks/{_server.ts-CP5cRLhW.js → _server.ts-kax5Szn0.js} +5 -4
  79. package/web/build/server/chunks/_server.ts-kax5Szn0.js.map +1 -0
  80. package/web/build/server/chunks/_server.ts-vS3A9t3f.js +123 -0
  81. package/web/build/server/chunks/_server.ts-vS3A9t3f.js.map +1 -0
  82. package/web/build/server/chunks/{agent-sessions-2XGojSJ_.js → agent-sessions-BArYLTW6.js} +1023 -135
  83. package/web/build/server/chunks/agent-sessions-BArYLTW6.js.map +1 -0
  84. package/web/build/server/chunks/{git-review-jZ_JJYfv.js → git-review-hZSqk_1P.js} +54 -21
  85. package/web/build/server/chunks/{git-review-jZ_JJYfv.js.map → git-review-hZSqk_1P.js.map} +1 -1
  86. package/web/build/server/chunks/{handler-B_8yUXji.js → handler-DbFqzg7l.js} +3 -3
  87. package/web/build/server/chunks/{handler-B_8yUXji.js.map → handler-DbFqzg7l.js.map} +1 -1
  88. package/web/build/server/chunks/{hooks.server-rF_eBCff.js → hooks.server-BEl7rVad.js} +4 -3
  89. package/web/build/server/chunks/{hooks.server-rF_eBCff.js.map → hooks.server-BEl7rVad.js.map} +1 -1
  90. package/web/build/server/chunks/{index.js-CGw8_Q7V.js → index.js-BB9DZBdR.js} +4 -4
  91. package/web/build/server/chunks/index.js-BB9DZBdR.js.map +1 -0
  92. package/web/build/server/chunks/manifest.js-Dk6YX1jw.js +65 -0
  93. package/web/build/server/chunks/manifest.js-Dk6YX1jw.js.map +1 -0
  94. package/web/build/server/chunks/{remote-1g4ayag-BMpjk8UD.js → remote-1g4ayag-Bz1vj8X1.js} +6 -6
  95. package/web/build/server/chunks/remote-1g4ayag-Bz1vj8X1.js.map +1 -0
  96. package/web/build/server/chunks/{remote-wh6t6j-dYLCg8ks.js → remote-wh6t6j-DSwM3S9h.js} +44 -10
  97. package/web/build/server/chunks/remote-wh6t6j-DSwM3S9h.js.map +1 -0
  98. package/web/build/server/chunks/{validate-Da6e71V3.js → validate-CXjTRGnR.js} +34 -17
  99. package/web/build/server/chunks/validate-CXjTRGnR.js.map +1 -0
  100. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css +0 -1
  101. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  102. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.gz +0 -0
  103. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css +0 -1
  104. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.br +0 -0
  105. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.gz +0 -0
  106. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css +0 -1
  107. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.br +0 -0
  108. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.gz +0 -0
  109. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css +0 -1
  110. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css.gz +0 -0
  112. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js +0 -1
  113. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.br +0 -1
  114. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.gz +0 -0
  115. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.br +0 -0
  116. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.gz +0 -0
  117. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.br +0 -0
  118. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.gz +0 -0
  119. package/web/build/client/_app/immutable/chunks/DpxY75GB.js +0 -215
  120. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.br +0 -0
  121. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.gz +0 -0
  122. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.br +0 -1
  123. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.gz +0 -0
  124. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js +0 -2
  125. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.br +0 -0
  126. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.gz +0 -0
  127. package/web/build/client/_app/immutable/entry/start.B012TSbW.js +0 -1
  128. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.br +0 -1
  129. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.gz +0 -0
  130. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js +0 -1
  131. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.br +0 -0
  132. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.gz +0 -0
  133. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js +0 -1
  134. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.br +0 -0
  135. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.gz +0 -0
  136. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js +0 -10
  137. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.br +0 -0
  138. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.gz +0 -0
  139. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js +0 -173
  140. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.br +0 -0
  141. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.gz +0 -0
  142. package/web/build/server/chunks/0-D7NSxn1-.js +0 -11
  143. package/web/build/server/chunks/1-DboYz8Fw.js +0 -7
  144. package/web/build/server/chunks/2-B7LX817Q.js +0 -7
  145. package/web/build/server/chunks/2-B7LX817Q.js.map +0 -1
  146. package/web/build/server/chunks/3-DgJ_LmTL.js +0 -7
  147. package/web/build/server/chunks/_server.ts-CP5cRLhW.js.map +0 -1
  148. package/web/build/server/chunks/agent-sessions-2XGojSJ_.js.map +0 -1
  149. package/web/build/server/chunks/index.js-CGw8_Q7V.js.map +0 -1
  150. package/web/build/server/chunks/manifest.js-CImZFS6G.js +0 -58
  151. package/web/build/server/chunks/manifest.js-CImZFS6G.js.map +0 -1
  152. package/web/build/server/chunks/remote-1g4ayag-BMpjk8UD.js.map +0 -1
  153. package/web/build/server/chunks/remote-wh6t6j-dYLCg8ks.js.map +0 -1
  154. package/web/build/server/chunks/validate-Da6e71V3.js.map +0 -1
package/README.md CHANGED
@@ -47,30 +47,38 @@ diff-review --agent
47
47
 
48
48
  Al enviarlo a un agente, en vez de copiar el prompt pregunta el modelo (con `--agent`, primero el agente):
49
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
50
+ 1. **Agente**: los instalados que encuentra (Claude Code, Cursor CLI, GitHub Copilot 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
51
  escritorio (`%APPDATA%\Claude\claude-code\<versión>\…\claude.exe`) y `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd`;
52
52
  en Linux/macOS, `~/.local/bin/claude`, `~/.claude/local/claude` y `~/.local/bin/cursor-agent`.
53
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).
54
+ `ANTHROPIC_MODEL`, `~/.cursor/cli-config.json` o `~/.copilot/settings.json`; si no hay, no se pasa `--model`).
55
+ También lista los conocidos (para Claude, los alias `opus` / `sonnet` / `haiku`; para Copilot, `auto`) y "Otro…"
56
+ para escribir un id. Por ACP, la lista sale de lo que ofrece el agente.
57
+
58
+ Por defecto el agente corre por ACP en la web. Con `--headless`, el prompt va por stdin y la salida se ve en la
59
+ terminal (Claude/Copilot con permisos acotados; Cursor con `--force`). Si no hay agentes instalados, sigue con el
60
+ portapapeles. Sin terminal interactiva usa el primer agente con su modelo por defecto.
61
+
62
+ Si el agente no está autenticado, la CLI pregunta **¿Querés iniciar sesión en {agente} ahora?** (al listar
63
+ modelos y otra vez **antes de abrir la web** si ya elegiste agente), corre el login **en la misma terminal**,
64
+ espera exit 0 y reintenta; si cancelás, sigue (lista fija de modelos / web con CTA). Con **Copilot** podés
65
+ elegir **GitHub.com** o **Enterprise** (hostname); no se fuerza un host por env. En github.com, si tenés
66
+ varias cuentas, elegí la corporativa en el browser del OAuth. En el header (arriba a la derecha) se muestra la **cuenta activa** del agente; el menú tiene
67
+ **Cambiar cuenta** (en Copilot también cierra sesiones de `gh`, porque si no el logout OAuth no alcanza).
68
+ Ojo: si tenés `GH_TOKEN` / `GITHUB_TOKEN` / `COPILOT_GITHUB_TOKEN` en el entorno, hay que sacarlos a mano.
65
69
 
66
70
  #### Por ACP, con la web (Claude Code, Cursor CLI, GitHub Copilot CLI)
67
71
 
68
- Si el agente elegido habla ACP, la CLI abre la UI enseguida y el agente corre dentro del server local. Claude Code
72
+ Los agentes integrados corren por ACP: la CLI abre la UI enseguida y el agente vive dentro del server local. Claude Code
69
73
  va por el adaptador `@agentclientprotocol/claude-agent-acp` (se baja con `npx` la primera vez); Cursor
70
- (`cursor-agent acp`) y Copilot (`copilot --acp`) lo traen de fábrica. Codex y Gemini siguen por la terminal.
74
+ (`cursor-agent acp`) y Copilot (`copilot --acp`) lo traen de fábrica.
71
75
 
72
76
  - El select de modelo muestra los que ofrece cada agente (se consultan por ACP la primera vez).
73
77
  - La web muestra lo que va haciendo (comandos, archivos que lee, plan). La terminal solo avisa fallos duros o JSON válido.
78
+ - Autenticación ACP: el client anuncia `auth.terminal`; Cursor usa `authenticate`, Claude/Copilot abren login
79
+ interactivo (métodos `type: terminal` o `_meta.terminal-auth`) en la misma TTY desde la CLI (modelos + justo
80
+ antes de abrir la web), o como proceso oculto + browser OAuth desde la web vía **Iniciar sesión en {agente}**.
81
+ Si hace falta más tarde, la web usa **needs_auth**.
74
82
  - Al terminar cada turno, el server valida el JSON; si falta algo se lo pide al agente hasta dos veces y, cuando valida,
75
83
  la web abre el reporte.
76
84
  - Cada pedido de permiso pasa por una política fija: leer dentro del repo, escribir únicamente el JSON del review
@@ -13,13 +13,15 @@
13
13
 
14
14
  import { spawn, spawnSync } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
- import { existsSync } from 'node:fs';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
17
  import { createServer } from 'node:net';
18
18
  import { dirname, join } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import * as p from '@clack/prompts';
21
21
  import { AGENTS, detectAgents, modelChoices, resolvedRunModel, runAgent } from '../lib/agents.mjs';
22
22
  import { listAcpModelsCached, supportsAcp } from '../lib/acp.mjs';
23
+ import { readModelsCache } from '../lib/models-cache.mjs';
24
+ import { isAuthRequiredError, withAgentLoginRetry } from '../lib/agent-login.mjs';
23
25
  import { buildAcpModelSelectOptions, sortModelSelectOptions } from '../lib/model-label.mjs';
24
26
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
25
27
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
@@ -32,6 +34,46 @@ import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mj
32
34
  import { stampReviewProvenance } from '../lib/review-meta.mjs';
33
35
  import { runValidateCli } from '../lib/check-report.mjs';
34
36
 
37
+ /**
38
+ * Copilot: elegir github.com vs Enterprise (el usuario elige; no se fuerza por env).
39
+ * @returns {Promise<string | false>} host vacío = github.com; false = canceló
40
+ */
41
+ async function pickCopilotLoginHost() {
42
+ const where = await p.select({
43
+ message: '¿Con qué cuenta de GitHub querés iniciar sesión?',
44
+ options: [
45
+ {
46
+ value: 'github.com',
47
+ label: 'GitHub.com',
48
+ hint: 'Personal u organización en github.com — elegí la cuenta en el browser'
49
+ },
50
+ {
51
+ value: 'enterprise',
52
+ label: 'GitHub Enterprise',
53
+ hint: 'Host tipo empresa.ghe.com'
54
+ }
55
+ ],
56
+ initialValue: 'github.com'
57
+ });
58
+ if (p.isCancel(where)) {
59
+ p.cancel('Cancelado.');
60
+ return false;
61
+ }
62
+ if (where === 'enterprise') {
63
+ const host = await p.text({
64
+ message: 'Hostname de Enterprise',
65
+ placeholder: 'empresa.ghe.com',
66
+ validate: (v) => (String(v || '').trim() ? undefined : 'Ingresá el hostname')
67
+ });
68
+ if (p.isCancel(host)) {
69
+ p.cancel('Cancelado.');
70
+ return false;
71
+ }
72
+ return String(host).trim();
73
+ }
74
+ return '';
75
+ }
76
+
35
77
  const here = dirname(fileURLToPath(import.meta.url));
36
78
  const packageRoot = join(here, '..');
37
79
  // `DIFF_REVIEW_OUT=build-next` cuando `web/build` está bloqueado por un server en marcha (EBUSY en Windows).
@@ -43,6 +85,19 @@ const buildDir = (() => {
43
85
  })();
44
86
  const entry = join(buildDir, 'index.js');
45
87
 
88
+ /** Versión del paquete instalado (la del package.json que viaja con el CLI). */
89
+ const VERSION = (() => {
90
+ try {
91
+ return String(JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version || '');
92
+ } catch {
93
+ return '';
94
+ }
95
+ })();
96
+ const TITLE = VERSION ? `diff-review v${VERSION}` : 'diff-review';
97
+
98
+ /** Agentes cuyos modelos ya se consultaron en vivo en esta corrida: la sesión ACP anda y no hace falta otro probe. */
99
+ const liveModelsOk = new Set();
100
+
46
101
  const DEFAULT_HOST = '127.0.0.1';
47
102
  const DEFAULT_PORT = 5190;
48
103
 
@@ -62,7 +117,7 @@ function resolveNodeBin() {
62
117
  }
63
118
 
64
119
  function printHelp() {
65
- console.log(`diff-review — review de branch con tu agente + UI local.
120
+ console.log(`${TITLE} — review de branch con tu agente + UI local.
66
121
 
67
122
  Usage:
68
123
  diff-review [options]
@@ -75,9 +130,10 @@ un solo select lista el portapapeles y cada agente detectado.
75
130
  Si pasás --branch y --base juntos, saltea los selects.
76
131
  Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
77
132
  y diff-review lo lanza con el prompt.
78
- Si el agente habla ACP (Claude Code), corre desde la UI web: ves el
79
- proceso en vivo y después podés preguntarle o pedirle cambios al reporte.
80
- Con --headless corre en la terminal como antes.
133
+ Los agentes integrados (Claude Code, Cursor CLI, Copilot) corren por ACP
134
+ desde la UI web: ves el proceso en vivo y después podés preguntarle o
135
+ pedirle cambios al reporte. Si falta login, la CLI ofrece abrirlo en una
136
+ terminal y reintentar. Con --headless corren en la terminal.
81
137
  Con --ui: abre la UI de inmediato (web-first).
82
138
 
83
139
  Options:
@@ -89,6 +145,7 @@ Options:
89
145
  --agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
90
146
  --headless Con agente: correrlo en la terminal (sin ACP ni chat en la web)
91
147
  --no-open No abrir el navegador
148
+ -v, --version Mostrar la versión
92
149
  -h, --help Esta ayuda
93
150
 
94
151
  Requiere git en PATH. Los reportes viven en localStorage del navegador.
@@ -101,6 +158,7 @@ function parseArgs(argv) {
101
158
  host: DEFAULT_HOST,
102
159
  open: true,
103
160
  help: false,
161
+ version: false,
104
162
  ui: false,
105
163
  agent: false,
106
164
  headless: false,
@@ -110,6 +168,7 @@ function parseArgs(argv) {
110
168
  for (let i = 0; i < argv.length; i++) {
111
169
  const a = argv[i];
112
170
  if (a === '-h' || a === '--help') opts.help = true;
171
+ else if (a === '-v' || a === '--version') opts.version = true;
113
172
  else if (a === '--no-open') opts.open = false;
114
173
  else if (a === '--ui') opts.ui = true;
115
174
  else if (a === '--agent') opts.agent = true;
@@ -257,7 +316,7 @@ async function resolveBranchBase(launch, opts, pickOpts = {}) {
257
316
 
258
317
  const options = branchOptions(launch.listed);
259
318
  if (!pickOpts.skipIntro) {
260
- p.intro('diff-review');
319
+ p.intro(TITLE);
261
320
  p.log.info(launch.repo);
262
321
  }
263
322
 
@@ -405,6 +464,7 @@ function copyPromptOrWarn(prompt) {
405
464
 
406
465
  /**
407
466
  * Opciones de modelo: las que ofrece el agente por ACP (si va a correr así) o, si no, la lista fija.
467
+ * Si ACP falla por auth, ofrece el login (misma TTY o proceso oculto), espera exit 0 y reintenta (misma lógica que la web).
408
468
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
409
469
  * @param {{ acpCwd?: string }} opts
410
470
  */
@@ -415,26 +475,82 @@ async function modelOptions(agent, { acpCwd }) {
415
475
  options: sortModelSelectOptions(fixed.options)
416
476
  };
417
477
  if (!acpCwd) return sortedFixed;
478
+
479
+ // Siempre se le pregunta al agente (tarda unos segundos, pero la lista es la real); la caché solo
480
+ // sirve de respaldo si la consulta falla.
481
+ const fetchModels = () => listAcpModelsCached(agent, { cwd: acpCwd, force: true });
482
+
483
+ /** Última lista conocida, aunque tenga más de 24 h; si no hay, la fija. */
484
+ const fallback = () => {
485
+ const hit = readModelsCache(agent.def.id, { ttlMs: Infinity });
486
+ if (!hit?.models.length) {
487
+ p.log.warn('Uso la lista fija de modelos.');
488
+ return sortedFixed;
489
+ }
490
+ p.log.warn(`Uso la última lista conocida (${hit.fetchedAt.slice(0, 10)}).`);
491
+ return buildAcpModelSelectOptions({ models: hit.models, currentModel: hit.currentModel }, fixed);
492
+ };
493
+
494
+ /** @param {{ models: { value: string, name: string }[], currentModel: string }} result */
495
+ const fromLive = (result) => {
496
+ liveModelsOk.add(agent.def.id);
497
+ if (!result.models.length) return sortedFixed;
498
+ return buildAcpModelSelectOptions(
499
+ { models: result.models, currentModel: result.currentModel },
500
+ fixed
501
+ );
502
+ };
503
+
418
504
  const spin = p.spinner();
419
505
  spin.start(`Consultando los modelos de ${agent.def.label}`);
420
506
  try {
421
- const { models, currentModel, fromCache } = await listAcpModelsCached(agent, { cwd: acpCwd });
422
- spin.stop(
423
- fromCache
424
- ? `Modelos de ${agent.def.label} (caché)`
425
- : `Modelos de ${agent.def.label}`
426
- );
427
- if (!models.length) return sortedFixed;
428
- // Misma fusión/labels/orden que la UI.
429
- return buildAcpModelSelectOptions({ models, currentModel }, fixed);
507
+ const result = await fetchModels();
508
+ spin.stop(`Modelos de ${agent.def.label} (${result.models.length})`);
509
+ return fromLive(result);
430
510
  } catch (err) {
431
- spin.stop(`No pude consultar los modelos (${err instanceof Error ? err.message.split('\n')[0] : err}).`);
432
- return sortedFixed;
511
+ const detail = err instanceof Error ? err.message.split('\n')[0] : String(err);
512
+ spin.stop(`No pude consultar los modelos (${detail}).`);
513
+
514
+ if (!isInteractive() || !isAuthRequiredError(err)) return fallback();
515
+
516
+ const outcome = await withAgentLoginRetry(() => fetchModels(), {
517
+ agent,
518
+ cwd: acpCwd,
519
+ priorError: err,
520
+ askToLogin: async ({ message, agentLabel, displayCmd }) => {
521
+ p.log.warn(message);
522
+ const answer = await p.confirm({
523
+ message: `¿Querés iniciar sesión en ${agentLabel} ahora?`,
524
+ initialValue: true
525
+ });
526
+ if (p.isCancel(answer)) {
527
+ p.cancel('Cancelado.');
528
+ return false;
529
+ }
530
+ if (displayCmd) p.log.step(displayCmd);
531
+ return Boolean(answer);
532
+ },
533
+ pickCopilotHost: pickCopilotLoginHost,
534
+ onStatus: (msg) => p.log.info(msg)
535
+ });
536
+
537
+ if (outcome.ok) {
538
+ p.log.success(`Modelos de ${agent.def.label} (${outcome.value.models.length})`);
539
+ return fromLive(outcome.value);
540
+ }
541
+ if (outcome.cancelled) {
542
+ p.log.warn('Podés autenticarte después desde la web.');
543
+ return fallback();
544
+ }
545
+ const after = outcome.error instanceof Error ? outcome.error.message.split('\n')[0] : String(outcome.error);
546
+ p.log.warn(`No pude consultar los modelos tras el login (${after}).`);
547
+ return fallback();
433
548
  }
434
549
  }
435
550
 
436
551
  /**
437
552
  * Modelo del agente: autocomplete con búsqueda; sin TTY, el default (string vacío).
553
+ * `""` es válido (= "Por defecto del agente", no se pasa --model); no usar ensureValue.
438
554
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
439
555
  * @param {{ acpCwd?: string }} [opts] con `acpCwd`, la lista sale del agente por ACP
440
556
  */
@@ -442,17 +558,17 @@ async function pickModel(agent, opts = {}) {
442
558
  if (!isInteractive()) return '';
443
559
 
444
560
  const { options, initialValue } = await modelOptions(agent, opts);
445
- return /** @type {string} */ (
446
- ensureValue(
447
- await autocompleteEs({
448
- message: 'Modelo',
449
- options,
450
- initialValue,
451
- placeholder: 'opus, sonnet, gpt…'
452
- }),
453
- 'modelo'
454
- )
455
- );
561
+ const value = await autocompleteEs({
562
+ message: 'Modelo',
563
+ options,
564
+ initialValue,
565
+ placeholder: 'opus, sonnet, gpt…'
566
+ });
567
+ if (p.isCancel(value)) {
568
+ p.cancel('Cancelado.');
569
+ process.exit(0);
570
+ }
571
+ return /** @type {string} */ (value ?? '');
456
572
  }
457
573
 
458
574
  /**
@@ -572,6 +688,76 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
572
688
  return /** @type {{ path: string }} */ (result);
573
689
  }
574
690
 
691
+ /**
692
+ * Antes de abrir la web: forzar un probe ACP (sin caché) y, si hace falta auth, login in-flow en esta terminal.
693
+ * Así no llegamos a LiveReviewView sin sesión cuando el usuario ya eligió el agente en la CLI.
694
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
695
+ * @param {string} repo
696
+ * @param {boolean} useClack
697
+ * @returns {Promise<boolean>} true si el probe quedó ok (logueado o ya lo estaba)
698
+ */
699
+ async function ensureAgentAuthBeforeUi(agent, repo, useClack) {
700
+ // Si recién listamos los modelos en vivo, la sesión ya anduvo: no hace falta arrancar el agente otra vez.
701
+ if (liveModelsOk.has(agent.def.id)) return true;
702
+ const probe = () => listAcpModelsCached(agent, { cwd: repo, force: true });
703
+ try {
704
+ await probe();
705
+ return true;
706
+ } catch (err) {
707
+ if (!isInteractive() || !isAuthRequiredError(err)) {
708
+ const detail = err instanceof Error ? err.message.split('\n')[0] : String(err);
709
+ if (useClack) p.log.warn(detail);
710
+ else console.warn(detail);
711
+ return false;
712
+ }
713
+
714
+ const outcome = await withAgentLoginRetry(() => probe(), {
715
+ agent,
716
+ cwd: repo,
717
+ priorError: err,
718
+ askToLogin: async ({ message, agentLabel }) => {
719
+ if (useClack) {
720
+ p.log.warn(message);
721
+ const answer = await p.confirm({
722
+ message: `¿Querés iniciar sesión en ${agentLabel} ahora?`,
723
+ initialValue: true
724
+ });
725
+ if (p.isCancel(answer)) {
726
+ p.cancel('Cancelado.');
727
+ return false;
728
+ }
729
+ return Boolean(answer);
730
+ }
731
+ console.warn(message);
732
+ return true;
733
+ },
734
+ pickCopilotHost: useClack ? pickCopilotLoginHost : async () => '',
735
+ onStatus: (msg) => {
736
+ if (useClack) p.log.info(msg);
737
+ else console.log(msg);
738
+ }
739
+ });
740
+
741
+ if (outcome.ok) {
742
+ if (useClack) p.log.success(`Sesión de ${agent.def.label} lista.`);
743
+ else console.log(`Sesión de ${agent.def.label} lista.`);
744
+ return true;
745
+ }
746
+ if (outcome.cancelled) {
747
+ const warn =
748
+ 'Sin iniciar sesión: la web va a pedir autenticación al lanzar el agente.';
749
+ if (useClack) p.log.warn(warn);
750
+ else console.warn(warn);
751
+ return false;
752
+ }
753
+ const after =
754
+ outcome.error instanceof Error ? outcome.error.message.split('\n')[0] : String(outcome.error);
755
+ if (useClack) p.log.warn(after);
756
+ else console.warn(after);
757
+ return false;
758
+ }
759
+ }
760
+
575
761
  /**
576
762
  * El agente corre dentro del server de la UI (ACP): la web muestra el proceso y abre el reporte; la terminal ve un
577
763
  * resumen. La sesión queda viva para preguntas y cambios desde el reporte.
@@ -580,6 +766,7 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
580
766
  * @param {{ host: string, port: number, open: boolean }} opts
581
767
  */
582
768
  async function runAgentInUi(agent, { repo, branch, base, model, outputFilename, useClack }, opts) {
769
+ await ensureAgentAuthBeforeUi(agent, repo, useClack);
583
770
  const id = randomUUID();
584
771
  const msg = `Lanzando ${agent.def.label} (${model || 'modelo por defecto'}). El progreso se ve en la web; Ctrl+C corta.`;
585
772
  if (useClack) p.outro(msg);
@@ -746,6 +933,10 @@ async function main() {
746
933
  }
747
934
 
748
935
  const opts = parseArgs(rawArgv);
936
+ if (opts.version) {
937
+ console.log(VERSION || 'desconocida');
938
+ process.exit(0);
939
+ }
749
940
  if (opts.help) {
750
941
  printHelp();
751
942
  process.exit(0);
@@ -844,7 +1035,7 @@ async function main() {
844
1035
  const existing = isInteractive() ? existingReviewOutput(launch.repo, outputFilename) : null;
845
1036
  const useClack = prompted || (isInteractive() && (detected.length > 0 || existing != null));
846
1037
  // Con flags de branch/base no hubo intro, pero estos selects abren sesión de Clack igual.
847
- if (useClack && !prompted) p.intro('diff-review');
1038
+ if (useClack && !prompted) p.intro(TITLE);
848
1039
 
849
1040
  let result = existing ? await reuseExistingReview(existing, outputFilename) : null;
850
1041
  /** @type {import('../lib/agents.mjs').DetectedAgent | null} */
package/lib/acp.mjs CHANGED
@@ -9,9 +9,27 @@ import { Readable, Writable } from "node:stream";
9
9
  import * as acp from "@agentclientprotocol/sdk";
10
10
  import { spawnCommand } from "./agents.mjs";
11
11
  import { permissionOutcome } from "./acp-policy.mjs";
12
- import { looksLikeAuthFailure } from "./agent-login.mjs";
12
+ import { authFailureMessage, groupAuthMethods, looksLikeAuthFailure } from "./agent-login.mjs";
13
13
  import { readModelsCache, writeModelsCache } from "./models-cache.mjs";
14
14
 
15
+ /**
16
+ * session/new (u otra op) requiere login interactivo (terminal / terminal-meta).
17
+ * No es un fallo genérico: la UI debe abrir el login y reintentar.
18
+ */
19
+ export class AcpAuthRequiredError extends Error {
20
+ /**
21
+ * @param {string} message
22
+ * @param {{ methods?: any[], agentId?: string, agentLabel?: string }} [info]
23
+ */
24
+ constructor(message, info = {}) {
25
+ super(message);
26
+ this.name = "AcpAuthRequiredError";
27
+ this.methods = Array.isArray(info.methods) ? info.methods : [];
28
+ this.agentId = info.agentId || "";
29
+ this.agentLabel = info.agentLabel || "";
30
+ }
31
+ }
32
+
15
33
  /**
16
34
  * @typedef {import('./agents.mjs').DetectedAgent} DetectedAgent
17
35
  * @typedef {import('@agentclientprotocol/sdk').SessionUpdate} SessionUpdate
@@ -40,9 +58,11 @@ import { readModelsCache, writeModelsCache } from "./models-cache.mjs";
40
58
  * models: ModelOption[],
41
59
  * currentModel: string,
42
60
  * modelWarning?: string,
61
+ * supportsLogout: boolean,
43
62
  * setModel: (model: string) => Promise<{ currentModel: string, warning?: string }>,
44
63
  * prompt: (text: string) => Promise<{ stopReason: string }>,
45
64
  * cancel: () => Promise<void>,
65
+ * logout: () => Promise<void>,
46
66
  * close: () => void,
47
67
  * closed: Promise<void>,
48
68
  * stderrTail: () => string,
@@ -84,7 +104,7 @@ const ACP_START_TIMEOUT_MS = 90_000;
84
104
  * @returns {Promise<AcpSession>}
85
105
  */
86
106
  export async function startAcpSession(agent, opts) {
87
- if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
107
+ if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no se puede usar desde la web.`);
88
108
  const { file, args, env, sessionMeta } = agent.def.acp({ path: agent.path });
89
109
  const child = spawnCommand(file, args, {
90
110
  cwd: opts.cwd,
@@ -163,15 +183,27 @@ export async function startAcpSession(agent, opts) {
163
183
  try {
164
184
  const init = await conn.agent.request(acp.methods.agent.initialize, {
165
185
  protocolVersion: acp.PROTOCOL_VERSION,
166
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
186
+ clientCapabilities: {
187
+ fs: { readTextFile: false, writeTextFile: false },
188
+ // terminal* ACP (PTY del agente): no. auth.terminal = login interactivo en una terminal nuestra.
189
+ terminal: false,
190
+ auth: {
191
+ terminal: true,
192
+ },
193
+ // Claude mira `clientCapabilities._meta["terminal-auth"]` (no `auth._meta`) para rellenar
194
+ // method._meta["terminal-auth"] con node+adapter --cli …; sin esto solo manda type:terminal + args.
195
+ _meta: { "terminal-auth": true },
196
+ },
167
197
  clientInfo: { name: "diff-review", version: "0" },
168
198
  });
169
199
 
170
200
  const authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
171
- /** Métodos donde el agente maneja el login por `authenticate` (no terminal interactiva). */
172
- const agentAuthMethods = authMethods.filter((m) => m && (!m.type || m.type === "agent") && m.id);
201
+ const grouped = groupAuthMethods(authMethods);
202
+ /** Solo type agent (p. ej. Cursor `cursor_login`). Nunca terminal ni Copilot `_meta.terminal-auth`. */
203
+ const agentAuthMethods = grouped.agent;
204
+ const interactiveAuthMethods = [...grouped.terminal, ...grouped.terminalMeta];
173
205
 
174
- /** Si session/new falla por auth, pedimos `authenticate` una vez y reintentamos. */
206
+ /** Si session/new falla por auth, pedimos `authenticate` una vez (métodos agent) y reintentamos. */
175
207
  const tryAuthenticate = async () => {
176
208
  for (const method of agentAuthMethods) {
177
209
  try {
@@ -184,6 +216,18 @@ export async function startAcpSession(agent, opts) {
184
216
  return false;
185
217
  };
186
218
 
219
+ const authRequired = (msg) => {
220
+ const label = agent.def.label;
221
+ const friendly =
222
+ authFailureMessage(agent.def.id, label, msg) ||
223
+ `Necesitás iniciar sesión en ${label}.`;
224
+ return new AcpAuthRequiredError(friendly, {
225
+ methods: interactiveAuthMethods.length ? interactiveAuthMethods : authMethods,
226
+ agentId: agent.def.id,
227
+ agentLabel: label,
228
+ });
229
+ };
230
+
187
231
  const openNewSession = async () => {
188
232
  try {
189
233
  return await conn.agent.request(acp.methods.agent.session.new, {
@@ -193,13 +237,20 @@ export async function startAcpSession(agent, opts) {
193
237
  });
194
238
  } catch (err) {
195
239
  const msg = err instanceof Error ? err.message : String(err);
196
- if (agentAuthMethods.length && looksLikeAuthFailure(msg) && (await tryAuthenticate())) {
240
+ if (!looksLikeAuthFailure(msg)) throw err;
241
+
242
+ // Cursor (y similares): authenticate + retry. No llamar authenticate para terminal / Copilot.
243
+ if (agentAuthMethods.length && (await tryAuthenticate())) {
197
244
  return await conn.agent.request(acp.methods.agent.session.new, {
198
245
  cwd: opts.cwd,
199
246
  mcpServers: [],
200
247
  ...(sessionMeta ? { _meta: sessionMeta } : {}),
201
248
  });
202
249
  }
250
+
251
+ if (interactiveAuthMethods.length || agentAuthMethods.length || looksLikeAuthFailure(msg)) {
252
+ throw authRequired(msg);
253
+ }
203
254
  throw err;
204
255
  }
205
256
  };
@@ -237,7 +288,7 @@ export async function startAcpSession(agent, opts) {
237
288
  const wanted = String(model || "").trim();
238
289
  if (!wanted || wanted === currentModel) return { currentModel };
239
290
  if (!models) {
240
- return { currentModel, warning: `El agente no deja elegir el modelo por ACP; usa el suyo.` };
291
+ return { currentModel, warning: `El agente no deja elegir el modelo; usa el suyo.` };
241
292
  }
242
293
  if (!models.options.some((o) => o.value === wanted)) {
243
294
  return {
@@ -261,6 +312,8 @@ export async function startAcpSession(agent, opts) {
261
312
  if (applied.warning) modelWarning = applied.warning;
262
313
  }
263
314
 
315
+ const supportsLogout = Boolean(init.agentCapabilities?.auth?.logout);
316
+
264
317
  clearTimeout(timer);
265
318
  return {
266
319
  sessionId,
@@ -269,6 +322,7 @@ export async function startAcpSession(agent, opts) {
269
322
  get currentModel() {
270
323
  return currentModel;
271
324
  },
325
+ supportsLogout,
272
326
  ...(modelWarning ? { modelWarning } : {}),
273
327
  setModel,
274
328
  prompt: async (text) => {
@@ -279,6 +333,13 @@ export async function startAcpSession(agent, opts) {
279
333
  return { stopReason: String(res.stopReason) };
280
334
  },
281
335
  cancel: () => conn.agent.notify(acp.methods.agent.session.cancel, { sessionId }),
336
+ /** ACP `logout` si el agente lo anunció; no garantiza nada sobre credenciales de `gh`. */
337
+ logout: async () => {
338
+ if (!supportsLogout) {
339
+ throw new Error(`${agent.def.label} no admite cerrar sesión desde acá.`);
340
+ }
341
+ await conn.agent.request(acp.methods.agent.logout, {});
342
+ },
282
343
  close,
283
344
  closed: conn.closed,
284
345
  stderrTail: () => stderr,
@@ -286,13 +347,28 @@ export async function startAcpSession(agent, opts) {
286
347
  } catch (err) {
287
348
  clearTimeout(timer);
288
349
  close();
350
+ if (err instanceof AcpAuthRequiredError) throw err;
289
351
  const tail = stderr.trim().split(/\r?\n/).slice(-8).join("\n");
290
352
  if (timedOut) {
291
353
  const msg = `Timeout (${Math.round(timeoutMs / 1000)}s) al lanzar ${agent.def.label}. ¿Está autenticado el CLI?`;
292
- throw new Error(tail ? `${msg}\n${tail}` : msg);
354
+ const full = tail ? `${msg}\n${tail}` : msg;
355
+ if (looksLikeAuthFailure(full)) {
356
+ throw new AcpAuthRequiredError(
357
+ authFailureMessage(agent.def.id, agent.def.label, full) || full,
358
+ { agentId: agent.def.id, agentLabel: agent.def.label }
359
+ );
360
+ }
361
+ throw new Error(full);
293
362
  }
294
363
  const reason = err instanceof Error ? err.message : String(err);
295
- throw new Error(tail ? `${reason}\n${tail}` : reason);
364
+ const full = tail ? `${reason}\n${tail}` : reason;
365
+ if (looksLikeAuthFailure(full)) {
366
+ throw new AcpAuthRequiredError(
367
+ authFailureMessage(agent.def.id, agent.def.label, full) || full,
368
+ { agentId: agent.def.id, agentLabel: agent.def.label }
369
+ );
370
+ }
371
+ throw new Error(full);
296
372
  }
297
373
  }
298
374