@jpkme/diff-review 0.1.16 → 0.1.17

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 +192 -21
  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.B04dzd8P.css +1 -0
  21. package/web/build/client/_app/immutable/assets/0.B04dzd8P.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/0.B04dzd8P.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/B-2kfLeA.js +1 -0
  33. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.br +0 -0
  34. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.gz +0 -0
  35. package/web/build/client/_app/immutable/chunks/{DHO7NTh1.js → B4SiKXUO.js} +2 -2
  36. package/web/build/client/_app/immutable/chunks/B4SiKXUO.js.br +0 -0
  37. package/web/build/client/_app/immutable/chunks/B4SiKXUO.js.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/{CXhIC34H.js → DA0YnpGR.js} +3 -3
  39. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/{cYiFfRa3.js → Dv85Hz5p.js} +1 -1
  42. package/web/build/client/_app/immutable/chunks/Dv85Hz5p.js.br +2 -0
  43. package/web/build/client/_app/immutable/chunks/Dv85Hz5p.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js +215 -0
  45. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js.gz +0 -0
  47. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js +2 -0
  48. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js.br +0 -0
  49. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js.gz +0 -0
  50. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js +1 -0
  51. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js.br +0 -0
  52. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js.gz +0 -0
  53. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js +1 -0
  54. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js.br +0 -0
  55. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js.gz +0 -0
  56. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js +1 -0
  57. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js.br +2 -0
  58. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js +12 -0
  60. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.js +340 -0
  63. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.js.br +0 -0
  64. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.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-8NUeB9cB.js +11 -0
  71. package/web/build/server/chunks/{0-D7NSxn1-.js.map → 0-8NUeB9cB.js.map} +1 -1
  72. package/web/build/server/chunks/1-BApN-ILJ.js +7 -0
  73. package/web/build/server/chunks/{1-DboYz8Fw.js.map → 1-BApN-ILJ.js.map} +1 -1
  74. package/web/build/server/chunks/2-Bq7iMeQx.js +7 -0
  75. package/web/build/server/chunks/2-Bq7iMeQx.js.map +1 -0
  76. package/web/build/server/chunks/3-W0cqofyE.js +7 -0
  77. package/web/build/server/chunks/{3-DgJ_LmTL.js.map → 3-W0cqofyE.js.map} +1 -1
  78. package/web/build/server/chunks/_server.ts-Bu1fdGSB.js +123 -0
  79. package/web/build/server/chunks/_server.ts-Bu1fdGSB.js.map +1 -0
  80. package/web/build/server/chunks/{_server.ts-CP5cRLhW.js → _server.ts-BuUNechu.js} +5 -4
  81. package/web/build/server/chunks/_server.ts-BuUNechu.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-BJ_JrFNP.js} +54 -21
  85. package/web/build/server/chunks/{git-review-jZ_JJYfv.js.map → git-review-BJ_JrFNP.js.map} +1 -1
  86. package/web/build/server/chunks/{handler-B_8yUXji.js → handler-Df_bJiCq.js} +3 -3
  87. package/web/build/server/chunks/{handler-B_8yUXji.js.map → handler-Df_bJiCq.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-Bb0CZLZ_.js} +4 -4
  91. package/web/build/server/chunks/index.js-Bb0CZLZ_.js.map +1 -0
  92. package/web/build/server/chunks/manifest.js-j442duM7.js +65 -0
  93. package/web/build/server/chunks/manifest.js-j442duM7.js.map +1 -0
  94. package/web/build/server/chunks/{remote-1g4ayag-BMpjk8UD.js → remote-1g4ayag-BxKnZQPn.js} +6 -6
  95. package/web/build/server/chunks/remote-1g4ayag-BxKnZQPn.js.map +1 -0
  96. package/web/build/server/chunks/{remote-wh6t6j-dYLCg8ks.js → remote-wh6t6j-B-AHrGaN.js} +44 -10
  97. package/web/build/server/chunks/remote-wh6t6j-B-AHrGaN.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
@@ -20,6 +20,7 @@ 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 { isAuthRequiredError, withAgentLoginRetry } from '../lib/agent-login.mjs';
23
24
  import { buildAcpModelSelectOptions, sortModelSelectOptions } from '../lib/model-label.mjs';
24
25
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
25
26
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
@@ -32,6 +33,46 @@ import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mj
32
33
  import { stampReviewProvenance } from '../lib/review-meta.mjs';
33
34
  import { runValidateCli } from '../lib/check-report.mjs';
34
35
 
36
+ /**
37
+ * Copilot: elegir github.com vs Enterprise (el usuario elige; no se fuerza por env).
38
+ * @returns {Promise<string | false>} host vacío = github.com; false = canceló
39
+ */
40
+ async function pickCopilotLoginHost() {
41
+ const where = await p.select({
42
+ message: '¿Con qué cuenta de GitHub querés iniciar sesión?',
43
+ options: [
44
+ {
45
+ value: 'github.com',
46
+ label: 'GitHub.com',
47
+ hint: 'Personal u organización en github.com — elegí la cuenta en el browser'
48
+ },
49
+ {
50
+ value: 'enterprise',
51
+ label: 'GitHub Enterprise',
52
+ hint: 'Host tipo empresa.ghe.com'
53
+ }
54
+ ],
55
+ initialValue: 'github.com'
56
+ });
57
+ if (p.isCancel(where)) {
58
+ p.cancel('Cancelado.');
59
+ return false;
60
+ }
61
+ if (where === 'enterprise') {
62
+ const host = await p.text({
63
+ message: 'Hostname de Enterprise',
64
+ placeholder: 'empresa.ghe.com',
65
+ validate: (v) => (String(v || '').trim() ? undefined : 'Ingresá el hostname')
66
+ });
67
+ if (p.isCancel(host)) {
68
+ p.cancel('Cancelado.');
69
+ return false;
70
+ }
71
+ return String(host).trim();
72
+ }
73
+ return '';
74
+ }
75
+
35
76
  const here = dirname(fileURLToPath(import.meta.url));
36
77
  const packageRoot = join(here, '..');
37
78
  // `DIFF_REVIEW_OUT=build-next` cuando `web/build` está bloqueado por un server en marcha (EBUSY en Windows).
@@ -75,9 +116,10 @@ un solo select lista el portapapeles y cada agente detectado.
75
116
  Si pasás --branch y --base juntos, saltea los selects.
76
117
  Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
77
118
  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.
119
+ Los agentes integrados (Claude Code, Cursor CLI, Copilot) corren por ACP
120
+ desde la UI web: ves el proceso en vivo y después podés preguntarle o
121
+ pedirle cambios al reporte. Si falta login, la CLI ofrece abrirlo en una
122
+ terminal y reintentar. Con --headless corren en la terminal.
81
123
  Con --ui: abre la UI de inmediato (web-first).
82
124
 
83
125
  Options:
@@ -405,6 +447,7 @@ function copyPromptOrWarn(prompt) {
405
447
 
406
448
  /**
407
449
  * Opciones de modelo: las que ofrece el agente por ACP (si va a correr así) o, si no, la lista fija.
450
+ * 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
451
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
409
452
  * @param {{ acpCwd?: string }} opts
410
453
  */
@@ -415,26 +458,85 @@ async function modelOptions(agent, { acpCwd }) {
415
458
  options: sortModelSelectOptions(fixed.options)
416
459
  };
417
460
  if (!acpCwd) return sortedFixed;
461
+
462
+ /** @param {boolean} [force] */
463
+ const fetchModels = (force = false) => listAcpModelsCached(agent, { cwd: acpCwd, force });
464
+
418
465
  const spin = p.spinner();
419
466
  spin.start(`Consultando los modelos de ${agent.def.label}`);
420
- try {
421
- const { models, currentModel, fromCache } = await listAcpModelsCached(agent, { cwd: acpCwd });
467
+
468
+ /**
469
+ * @param {{ models: { value: string, name: string }[], currentModel: string, fromCache: boolean }} result
470
+ */
471
+ const finishOk = (result) => {
422
472
  spin.stop(
423
- fromCache
473
+ result.fromCache
424
474
  ? `Modelos de ${agent.def.label} (caché)`
425
475
  : `Modelos de ${agent.def.label}`
426
476
  );
427
- if (!models.length) return sortedFixed;
428
- // Misma fusión/labels/orden que la UI.
429
- return buildAcpModelSelectOptions({ models, currentModel }, fixed);
477
+ if (!result.models.length) return sortedFixed;
478
+ return buildAcpModelSelectOptions(
479
+ { models: result.models, currentModel: result.currentModel },
480
+ fixed
481
+ );
482
+ };
483
+
484
+ try {
485
+ return finishOk(await fetchModels(false));
430
486
  } catch (err) {
431
- spin.stop(`No pude consultar los modelos (${err instanceof Error ? err.message.split('\n')[0] : err}).`);
487
+ const detail = err instanceof Error ? err.message.split('\n')[0] : String(err);
488
+ spin.stop(`No pude consultar los modelos (${detail}).`);
489
+
490
+ // Sin TTY o no es auth: misma degradación de antes.
491
+ if (!isInteractive() || !isAuthRequiredError(err)) return sortedFixed;
492
+
493
+ const outcome = await withAgentLoginRetry(() => fetchModels(true), {
494
+ agent,
495
+ cwd: acpCwd,
496
+ priorError: err,
497
+ askToLogin: async ({ message, agentLabel, displayCmd }) => {
498
+ p.log.warn(message);
499
+ const answer = await p.confirm({
500
+ message: `¿Querés iniciar sesión en ${agentLabel} ahora?`,
501
+ initialValue: true
502
+ });
503
+ if (p.isCancel(answer)) {
504
+ p.cancel('Cancelado.');
505
+ return false;
506
+ }
507
+ if (displayCmd) p.log.step(displayCmd);
508
+ return Boolean(answer);
509
+ },
510
+ pickCopilotHost: pickCopilotLoginHost,
511
+ onStatus: (msg) => p.log.info(msg)
512
+ });
513
+
514
+ if (outcome.ok) {
515
+ const result = outcome.value;
516
+ p.log.success(
517
+ result.fromCache
518
+ ? `Modelos de ${agent.def.label} (caché)`
519
+ : `Modelos de ${agent.def.label}`
520
+ );
521
+ if (!result.models.length) return sortedFixed;
522
+ return buildAcpModelSelectOptions(
523
+ { models: result.models, currentModel: result.currentModel },
524
+ fixed
525
+ );
526
+ }
527
+ if (outcome.cancelled) {
528
+ p.log.warn('Siguiendo con la lista fija de modelos. Podés autenticarte después desde la web.');
529
+ return sortedFixed;
530
+ }
531
+ const after = outcome.error instanceof Error ? outcome.error.message.split('\n')[0] : String(outcome.error);
532
+ p.log.warn(`No pude consultar los modelos tras el login (${after}). Sigo con la lista fija.`);
432
533
  return sortedFixed;
433
534
  }
434
535
  }
435
536
 
436
537
  /**
437
538
  * Modelo del agente: autocomplete con búsqueda; sin TTY, el default (string vacío).
539
+ * `""` es válido (= "Por defecto del agente", no se pasa --model); no usar ensureValue.
438
540
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
439
541
  * @param {{ acpCwd?: string }} [opts] con `acpCwd`, la lista sale del agente por ACP
440
542
  */
@@ -442,17 +544,17 @@ async function pickModel(agent, opts = {}) {
442
544
  if (!isInteractive()) return '';
443
545
 
444
546
  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
- );
547
+ const value = await autocompleteEs({
548
+ message: 'Modelo',
549
+ options,
550
+ initialValue,
551
+ placeholder: 'opus, sonnet, gpt…'
552
+ });
553
+ if (p.isCancel(value)) {
554
+ p.cancel('Cancelado.');
555
+ process.exit(0);
556
+ }
557
+ return /** @type {string} */ (value ?? '');
456
558
  }
457
559
 
458
560
  /**
@@ -572,6 +674,74 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
572
674
  return /** @type {{ path: string }} */ (result);
573
675
  }
574
676
 
677
+ /**
678
+ * Antes de abrir la web: forzar un probe ACP (sin caché) y, si hace falta auth, login in-flow en esta terminal.
679
+ * Así no llegamos a LiveReviewView sin sesión cuando el usuario ya eligió el agente en la CLI.
680
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
681
+ * @param {string} repo
682
+ * @param {boolean} useClack
683
+ * @returns {Promise<boolean>} true si el probe quedó ok (logueado o ya lo estaba)
684
+ */
685
+ async function ensureAgentAuthBeforeUi(agent, repo, useClack) {
686
+ const probe = () => listAcpModelsCached(agent, { cwd: repo, force: true });
687
+ try {
688
+ await probe();
689
+ return true;
690
+ } catch (err) {
691
+ if (!isInteractive() || !isAuthRequiredError(err)) {
692
+ const detail = err instanceof Error ? err.message.split('\n')[0] : String(err);
693
+ if (useClack) p.log.warn(detail);
694
+ else console.warn(detail);
695
+ return false;
696
+ }
697
+
698
+ const outcome = await withAgentLoginRetry(() => probe(), {
699
+ agent,
700
+ cwd: repo,
701
+ priorError: err,
702
+ askToLogin: async ({ message, agentLabel }) => {
703
+ if (useClack) {
704
+ p.log.warn(message);
705
+ const answer = await p.confirm({
706
+ message: `¿Querés iniciar sesión en ${agentLabel} ahora?`,
707
+ initialValue: true
708
+ });
709
+ if (p.isCancel(answer)) {
710
+ p.cancel('Cancelado.');
711
+ return false;
712
+ }
713
+ return Boolean(answer);
714
+ }
715
+ console.warn(message);
716
+ return true;
717
+ },
718
+ pickCopilotHost: useClack ? pickCopilotLoginHost : async () => '',
719
+ onStatus: (msg) => {
720
+ if (useClack) p.log.info(msg);
721
+ else console.log(msg);
722
+ }
723
+ });
724
+
725
+ if (outcome.ok) {
726
+ if (useClack) p.log.success(`Sesión de ${agent.def.label} lista.`);
727
+ else console.log(`Sesión de ${agent.def.label} lista.`);
728
+ return true;
729
+ }
730
+ if (outcome.cancelled) {
731
+ const warn =
732
+ 'Sin iniciar sesión: la web va a pedir autenticación al lanzar el agente.';
733
+ if (useClack) p.log.warn(warn);
734
+ else console.warn(warn);
735
+ return false;
736
+ }
737
+ const after =
738
+ outcome.error instanceof Error ? outcome.error.message.split('\n')[0] : String(outcome.error);
739
+ if (useClack) p.log.warn(after);
740
+ else console.warn(after);
741
+ return false;
742
+ }
743
+ }
744
+
575
745
  /**
576
746
  * El agente corre dentro del server de la UI (ACP): la web muestra el proceso y abre el reporte; la terminal ve un
577
747
  * resumen. La sesión queda viva para preguntas y cambios desde el reporte.
@@ -580,6 +750,7 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
580
750
  * @param {{ host: string, port: number, open: boolean }} opts
581
751
  */
582
752
  async function runAgentInUi(agent, { repo, branch, base, model, outputFilename, useClack }, opts) {
753
+ await ensureAgentAuthBeforeUi(agent, repo, useClack);
583
754
  const id = randomUUID();
584
755
  const msg = `Lanzando ${agent.def.label} (${model || 'modelo por defecto'}). El progreso se ve en la web; Ctrl+C corta.`;
585
756
  if (useClack) p.outro(msg);
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