@jpkme/diff-review 0.1.3 → 0.1.5

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 (97) hide show
  1. package/README.md +22 -6
  2. package/bin/diff-review.mjs +328 -18
  3. package/lib/autocomplete-es.mjs +143 -0
  4. package/lib/clipboard.mjs +29 -0
  5. package/lib/config.mjs +233 -0
  6. package/lib/config.test.mjs +73 -0
  7. package/lib/extract.mjs +111 -0
  8. package/lib/git.mjs +202 -0
  9. package/lib/git.test.mjs +29 -0
  10. package/lib/json-payload.mjs +31 -0
  11. package/lib/json-payload.test.mjs +22 -0
  12. package/lib/output-name.mjs +44 -0
  13. package/lib/output-name.test.mjs +37 -0
  14. package/lib/pick-folder.mjs +4 -0
  15. package/lib/prompt.mjs +160 -0
  16. package/lib/remote-repo.mjs +182 -0
  17. package/lib/review.mjs +228 -0
  18. package/lib/review.test.mjs +160 -0
  19. package/lib/validate.mjs +221 -0
  20. package/lib/validate.test.mjs +85 -0
  21. package/lib/wait-output.mjs +104 -0
  22. package/package.json +4 -2
  23. package/schema.json +12 -0
  24. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +1 -0
  25. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  26. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  27. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js +1 -0
  28. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +2 -0
  29. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
  30. package/web/build/client/_app/immutable/chunks/{C5moC2DW.js → CdHZDAAo.js} +42 -32
  31. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
  32. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
  33. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +2 -0
  34. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
  35. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
  36. package/web/build/client/_app/immutable/entry/{app.Dei8qxo7.js → app.BRzz8J51.js} +2 -2
  37. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
  38. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
  39. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +1 -0
  40. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
  41. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
  42. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +1 -0
  43. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +1 -0
  44. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
  45. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +66 -0
  46. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
  47. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
  48. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js +170 -0
  49. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
  50. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.gz +0 -0
  51. package/web/build/client/_app/version.json +1 -1
  52. package/web/build/client/_app/version.json.br +0 -0
  53. package/web/build/client/_app/version.json.gz +0 -0
  54. package/web/build/handler.js +3 -3
  55. package/web/build/index.js +3 -3
  56. package/web/build/server/chunks/1-qrn24yN5.js +7 -0
  57. package/web/build/server/chunks/1-qrn24yN5.js.map +1 -0
  58. package/web/build/server/chunks/2-C7_Loqyn.js +7 -0
  59. package/web/build/server/chunks/2-C7_Loqyn.js.map +1 -0
  60. package/web/build/server/chunks/{3-Zc5_qwgV.js → 3-DZ5PBhOc.js} +2 -2
  61. package/web/build/server/chunks/{3-Zc5_qwgV.js.map → 3-DZ5PBhOc.js.map} +1 -1
  62. package/web/build/server/chunks/{handler-BmuqxCCf.js → handler-DD4vP3bU.js} +3 -3
  63. package/web/build/server/chunks/{handler-BmuqxCCf.js.map → handler-DD4vP3bU.js.map} +1 -1
  64. package/web/build/server/chunks/{index.js-41EcOndB.js → index.js-IhFPHwmp.js} +150 -150
  65. package/web/build/server/chunks/index.js-IhFPHwmp.js.map +1 -0
  66. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js → manifest.js-Dk3RuAA5.js} +6 -6
  67. package/web/build/server/chunks/{manifest.js-KnmnjP9i.js.map → manifest.js-Dk3RuAA5.js.map} +1 -1
  68. package/web/build/server/chunks/{remote-1g4ayag-BgUNMUS8.js → remote-1g4ayag-BRL-uaKE.js} +632 -146
  69. package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.js.map +1 -0
  70. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css +0 -1
  71. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.br +0 -0
  72. package/web/build/client/_app/immutable/assets/2.Du8E-7-D.css.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.br +0 -0
  74. package/web/build/client/_app/immutable/chunks/C5moC2DW.js.gz +0 -0
  75. package/web/build/client/_app/immutable/chunks/WxPrACSs.js +0 -2
  76. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/WxPrACSs.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.br +0 -0
  79. package/web/build/client/_app/immutable/entry/app.Dei8qxo7.js.gz +0 -0
  80. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js +0 -1
  81. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.br +0 -1
  82. package/web/build/client/_app/immutable/entry/start.Fzxrn2l4.js.gz +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js +0 -1
  84. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.br +0 -0
  85. package/web/build/client/_app/immutable/nodes/1.Cn_iktkl.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js +0 -66
  87. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/2.vvYFHTy-.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js +0 -170
  90. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.br +0 -0
  91. package/web/build/client/_app/immutable/nodes/3.CeJ7dEsh.js.gz +0 -0
  92. package/web/build/server/chunks/1-Bjgeu1Iz.js +0 -7
  93. package/web/build/server/chunks/1-Bjgeu1Iz.js.map +0 -1
  94. package/web/build/server/chunks/2-BvMZU9fP.js +0 -7
  95. package/web/build/server/chunks/2-BvMZU9fP.js.map +0 -1
  96. package/web/build/server/chunks/index.js-41EcOndB.js.map +0 -1
  97. package/web/build/server/chunks/remote-1g4ayag-BgUNMUS8.js.map +0 -1
package/README.md CHANGED
@@ -22,7 +22,23 @@ O sin instalar:
22
22
  npx @jpkme/diff-review
23
23
  ```
24
24
 
25
- Abre `http://127.0.0.1:5190`. Opciones: `--port`, `--host`, `--no-open`.
25
+ ### Modos
26
+
27
+ Desde un **repo git**, el default es terminal-first:
28
+
29
+ 1. Elegís **branch** y **base** con prompts en la terminal (`@clack/prompts`; tipeá para filtrar).
30
+ 2. Copia el prompt al portapapeles y espera el JSON (`diff-rev_{branchTip}_{baseTip}.json`).
31
+ 3. Valida y abre el viewer con el reporte ya importado.
32
+
33
+ Si pasás `--branch` y `--base` juntos, saltea los selects. Si quedan iguales (diff vacío), abre la UI.
34
+
35
+ Para abrir la UI primero (sin esperar el JSON):
36
+
37
+ ```bash
38
+ diff-review --ui
39
+ ```
40
+
41
+ Opciones: `--port`, `--host`, `--base`, `--branch`, `--no-open`.
26
42
 
27
43
  Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
28
44
 
@@ -35,12 +51,12 @@ npm run build # build de producción → web/build
35
51
  npm start # mismo que el bin global
36
52
  ```
37
53
 
38
- ## Flujo
54
+ ## Flujo (UI)
39
55
 
40
- 1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` desde el repo y se precarga solo.
56
+ 1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` / `diff-review --ui` desde el repo.
41
57
  2. Copiá el prompt (o el comando de skill `/diff-review`).
42
- 3. Corrélo en tu agente; que escriba `diff-review-output.json`.
43
- 4. Arrastrá ese JSON a la dropzone. La UI hidrata los diffs con git.
58
+ 3. Corrélo en tu agente; que escriba `diff-rev_{branchTip}_{baseTip}.json` (también acepta nombres legacy).
59
+ 4. Arrastrá ese JSON a la dropzone (o dejá que el CLI lo importe solo). La UI hidrata los diffs con git.
44
60
 
45
61
  "Ver ejemplo" carga un reporte de muestra sin tocar un repo.
46
62
 
@@ -84,7 +100,7 @@ y minificados.
84
100
  .
85
101
  ├── bin/ # CLI (diff-review)
86
102
  ├── schema.json # contrato del JSON que escribe el agente
87
- ├── lib/ # git / extract (dev + tests; va embebido en el build)
103
+ ├── lib/ # git / extract / prompt (dev + tests; va en el paquete)
88
104
  └── web/ # UI SvelteKit → web/build en el paquete npm
89
105
  ```
90
106
 
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * CLI: levanta la UI local (build de SvelteKit) y abre el browser.
3
+ * CLI híbrido C:
4
+ * diff-review → terminal: elegir branch/base → prompt + wait JSON → viewer
5
+ * diff-review --ui → web-first (UI inmediata)
4
6
  *
5
- * diff-review
6
7
  * diff-review --port 5191
8
+ * diff-review --branch x --base y → saltea selects de Clack
7
9
  * diff-review --no-open
8
10
  */
9
11
 
@@ -12,6 +14,13 @@ import { existsSync } from 'node:fs';
12
14
  import { createServer } from 'node:net';
13
15
  import { dirname, join } from 'node:path';
14
16
  import { fileURLToPath } from 'node:url';
17
+ import * as p from '@clack/prompts';
18
+ import { autocompleteEs } from '../lib/autocomplete-es.mjs';
19
+ import { copyTextToClipboard } from '../lib/clipboard.mjs';
20
+ import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
21
+ import { makeOutputFilename } from '../lib/output-name.mjs';
22
+ import { buildPrompt } from '../lib/prompt.mjs';
23
+ import { waitForReviewOutput } from '../lib/wait-output.mjs';
15
24
 
16
25
  const here = dirname(fileURLToPath(import.meta.url));
17
26
  const packageRoot = join(here, '..');
@@ -37,14 +46,22 @@ function resolveNodeBin() {
37
46
  }
38
47
 
39
48
  function printHelp() {
40
- console.log(`diff-review — UI local para revisar un branch con tu agente.
49
+ console.log(`diff-review — review de branch con tu agente + UI local.
41
50
 
42
51
  Usage:
43
52
  diff-review [options]
44
53
 
54
+ 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.
56
+ Si pasás --branch y --base juntos, saltea los selects.
57
+ Con --ui: abre la UI de inmediato (web-first).
58
+
45
59
  Options:
46
60
  -p, --port <n> Puerto (default ${DEFAULT_PORT})
47
61
  --host <h> Host (default ${DEFAULT_HOST})
62
+ --ui Abrir la UI primero (sin esperar JSON)
63
+ --base <b> Base del merge-base (salta el select de base)
64
+ --branch <b> Branch a revisar (salta el select de branch)
48
65
  --no-open No abrir el navegador
49
66
  -h, --help Esta ayuda
50
67
 
@@ -53,12 +70,23 @@ Requiere git en PATH. Los reportes viven en localStorage del navegador.
53
70
  }
54
71
 
55
72
  function parseArgs(argv) {
56
- const opts = { port: DEFAULT_PORT, host: DEFAULT_HOST, open: true, help: false };
73
+ const opts = {
74
+ port: DEFAULT_PORT,
75
+ host: DEFAULT_HOST,
76
+ open: true,
77
+ help: false,
78
+ ui: false,
79
+ base: '',
80
+ branch: ''
81
+ };
57
82
  for (let i = 0; i < argv.length; i++) {
58
83
  const a = argv[i];
59
84
  if (a === '-h' || a === '--help') opts.help = true;
60
85
  else if (a === '--no-open') opts.open = false;
86
+ else if (a === '--ui') opts.ui = true;
61
87
  else if (a === '--host') opts.host = argv[++i] || opts.host;
88
+ else if (a === '--base') opts.base = argv[++i] || opts.base;
89
+ else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
62
90
  else if (a === '-p' || a === '--port') {
63
91
  const n = Number(argv[++i]);
64
92
  if (!Number.isFinite(n) || n <= 0) {
@@ -105,13 +133,172 @@ function canListen(host, port) {
105
133
  });
106
134
  }
107
135
 
108
- async function main() {
109
- const opts = parseArgs(process.argv.slice(2));
110
- if (opts.help) {
111
- printHelp();
136
+ /**
137
+ * @returns {{
138
+ * repo: string,
139
+ * listed: { branches: string[], current: string, defaultBase: string },
140
+ * remoteUrl?: string
141
+ * } | null}
142
+ */
143
+ function loadRepoLaunch() {
144
+ const cwd = process.cwd();
145
+ const root = tryResolveGitRoot(cwd);
146
+ if (!root) return null;
147
+ const listed = listBranches(root);
148
+ const remoteUrl = getRemoteUrl(root) || undefined;
149
+ return { repo: root, listed, remoteUrl };
150
+ }
151
+
152
+ function defaultBranch(listed) {
153
+ return listed.current && listed.current !== 'HEAD' ? listed.current : listed.branches[0] || '';
154
+ }
155
+
156
+ function defaultBase(listed, branch) {
157
+ const preferred = listed.branches.includes('develop') ? 'develop' : listed.defaultBase;
158
+ if (preferred && preferred !== branch) return preferred;
159
+ return listed.branches.find((b) => b !== branch) || preferred || '';
160
+ }
161
+
162
+ function branchOptions(listed) {
163
+ return listed.branches.map((b) => ({
164
+ value: b,
165
+ label: b,
166
+ hint: b === listed.current ? 'actual' : b === listed.defaultBase ? 'base típica' : undefined
167
+ }));
168
+ }
169
+
170
+ function ensureValue(value, label) {
171
+ if (p.isCancel(value)) {
172
+ p.cancel('Cancelado.');
112
173
  process.exit(0);
113
174
  }
175
+ if (value == null || value === '') {
176
+ p.cancel(`Falta ${label}.`);
177
+ process.exit(1);
178
+ }
179
+ return /** @type {string} */ (value);
180
+ }
181
+
182
+ /**
183
+ * Selects interactivos salvo que --branch y --base vengan juntos.
184
+ * @param {{ listed: { branches: string[], current: string, defaultBase: string }, repo: string }} launch
185
+ * @param {{ branch: string, base: string }} opts
186
+ */
187
+ async function resolveBranchBase(launch, opts) {
188
+ const flagBranch = String(opts.branch || '').trim();
189
+ const flagBase = String(opts.base || '').trim();
190
+ const bothFlags = Boolean(flagBranch && flagBase);
191
+
192
+ if (bothFlags) {
193
+ return { branch: flagBranch, base: flagBase, prompted: false };
194
+ }
195
+
196
+ if (!p.isTTY) {
197
+ const branch = flagBranch || defaultBranch(launch.listed);
198
+ const base = flagBase || defaultBase(launch.listed, branch);
199
+ return { branch, base, prompted: false };
200
+ }
201
+
202
+ if (!launch.listed.branches.length) {
203
+ p.cancel('No hay branches en este repo.');
204
+ process.exit(1);
205
+ }
206
+
207
+ const options = branchOptions(launch.listed);
208
+ p.intro('diff-review');
209
+ p.log.info(launch.repo);
210
+
211
+ let branch = flagBranch;
212
+ if (!branch) {
213
+ branch = ensureValue(
214
+ await autocompleteEs({
215
+ message: 'Branch a revisar',
216
+ options,
217
+ initialValue: defaultBranch(launch.listed) || undefined,
218
+ maxItems: 12
219
+ }),
220
+ 'branch'
221
+ );
222
+ } else {
223
+ p.log.step(`Branch: ${branch}`);
224
+ }
225
+
226
+ let base = flagBase;
227
+ if (!base) {
228
+ base = ensureValue(
229
+ await autocompleteEs({
230
+ message: 'Base (merge-base)',
231
+ options,
232
+ initialValue: defaultBase(launch.listed, branch) || undefined,
233
+ maxItems: 12
234
+ }),
235
+ 'base'
236
+ );
237
+ } else {
238
+ p.log.step(`Base: ${base}`);
239
+ }
240
+
241
+ while (branch === base) {
242
+ p.log.warn(`Branch y base son iguales (${branch}): el diff estaría vacío.`);
243
+ base = ensureValue(
244
+ await autocompleteEs({
245
+ message: 'Elegí otra base',
246
+ options,
247
+ initialValue: defaultBase(launch.listed, branch) || undefined,
248
+ maxItems: 12
249
+ }),
250
+ 'base'
251
+ );
252
+ }
253
+
254
+ return { branch, base, prompted: true };
255
+ }
256
+
257
+ function buildOutputFilename(repo, branch, base) {
258
+ try {
259
+ const tips = branchTips(repo, branch, base);
260
+ return makeOutputFilename({
261
+ branchSha: tips.branchSha,
262
+ baseSha: tips.baseSha
263
+ });
264
+ } catch (err) {
265
+ console.warn(
266
+ 'No pude resolver tips para el nombre del JSON:',
267
+ err instanceof Error ? err.message : err
268
+ );
269
+ return makeOutputFilename({});
270
+ }
271
+ }
272
+
273
+ function copyPromptOrWarn(prompt) {
274
+ if (copyTextToClipboard(prompt)) return true;
275
+ p.log.warn(
276
+ 'No pude copiar al portapapeles. Copiá el prompt a mano desde la terminal o reintentá.'
277
+ );
278
+ return false;
279
+ }
280
+
281
+ function explainWaitingForAgent(outputFilename, { copied, useClack }) {
282
+ const copyMsg = copied
283
+ ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
284
+ : 'Generá el JSON con tu agente de IA usando el prompt de esta sesión (no se pudo copiar solo al portapapeles).';
285
+ const waitMsg = `Aguardando el resultado del agente (${outputFilename} en la raíz del repo). La UI web se abre cuando ese archivo esté listo.`;
286
+
287
+ if (useClack) {
288
+ p.log.success(copyMsg);
289
+ p.log.info(waitMsg);
290
+ p.outro('Ctrl+C cancela la espera.');
291
+ return;
292
+ }
293
+ console.log(copyMsg);
294
+ console.log(waitMsg);
295
+ console.log('(Ctrl+C cancela la espera)');
296
+ }
114
297
 
298
+ /**
299
+ * @param {{ host: string, port: number, open: boolean, launchCwd: string, importFile?: string, openPath?: string }} cfg
300
+ */
301
+ async function startServer(cfg) {
115
302
  if (!existsSync(entry)) {
116
303
  console.error(`No encontré el build en ${entry}
117
304
  Corré desde el repo: npm run build
@@ -119,23 +306,23 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
119
306
  process.exit(1);
120
307
  }
121
308
 
122
- if (!(await canListen(opts.host, opts.port))) {
123
- console.error(`No se pudo usar ${opts.host}:${opts.port} (¿ocupado?). Probá --port otro.`);
309
+ if (!(await canListen(cfg.host, cfg.port))) {
310
+ console.error(`No se pudo usar ${cfg.host}:${cfg.port} (¿ocupado?). Probá --port otro.`);
124
311
  process.exit(1);
125
312
  }
126
313
 
127
- const origin = `http://${opts.host}:${opts.port}`;
314
+ const origin = `http://${cfg.host}:${cfg.port}`;
315
+ const openUrl = cfg.openPath ? `${origin}${cfg.openPath}` : origin;
128
316
  const nodeBin = resolveNodeBin();
129
- /** CWD del usuario al invocar el CLI (el server corre desde web/build). */
130
- const launchCwd = process.cwd();
131
317
  const child = spawn(nodeBin, [entry], {
132
318
  cwd: buildDir,
133
319
  env: {
134
320
  ...process.env,
135
- HOST: opts.host,
136
- PORT: String(opts.port),
321
+ HOST: cfg.host,
322
+ PORT: String(cfg.port),
137
323
  ORIGIN: origin,
138
- DIFF_REVIEW_LAUNCH_CWD: launchCwd
324
+ DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
325
+ ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
139
326
  },
140
327
  stdio: ['ignore', 'pipe', 'inherit'],
141
328
  windowsHide: false
@@ -152,7 +339,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
152
339
  if (!ready) {
153
340
  ready = true;
154
341
  console.log(`diff-review listo en ${terminalLink(origin)} — Ctrl+C para salir.`);
155
- if (opts.open) openBrowser(origin);
342
+ if (cfg.open) openBrowser(openUrl);
156
343
  }
157
344
  continue;
158
345
  }
@@ -175,4 +362,127 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
175
362
  process.on('SIGTERM', stop);
176
363
  }
177
364
 
178
- main();
365
+ async function runUiMode(opts, launch, launchCwd) {
366
+ const listed = launch?.listed;
367
+ const branch = String(opts.branch || '').trim() || (listed ? defaultBranch(listed) : '');
368
+ const base =
369
+ String(opts.base || '').trim() || (listed ? defaultBase(listed, branch) : 'develop');
370
+ const outputFilename = launch
371
+ ? buildOutputFilename(launch.repo, branch, base)
372
+ : makeOutputFilename({});
373
+
374
+ if (launch) {
375
+ console.log(`Repo: ${launch.repo}`);
376
+ console.log(`Branch: ${branch} (base: ${base})`);
377
+ const prompt = buildPrompt(
378
+ {
379
+ source: 'local',
380
+ repo: launch.repo,
381
+ branch,
382
+ base,
383
+ remoteUrl: launch.remoteUrl
384
+ },
385
+ { outputFilename }
386
+ );
387
+ if (copyTextToClipboard(prompt)) {
388
+ console.log('Prompt copiado al portapapeles.');
389
+ } else {
390
+ console.log('No pude copiar al portapapeles; pegá el prompt a mano desde la UI o reintentá.');
391
+ }
392
+ } else {
393
+ console.log('Sin repo git en el CWD — la UI arranca vacía.');
394
+ }
395
+
396
+ await startServer({
397
+ host: opts.host,
398
+ port: opts.port,
399
+ open: opts.open,
400
+ launchCwd,
401
+ openPath: launch ? `/?notify=prompt&out=${encodeURIComponent(outputFilename)}` : '/'
402
+ });
403
+ }
404
+
405
+ async function main() {
406
+ const opts = parseArgs(process.argv.slice(2));
407
+ if (opts.help) {
408
+ printHelp();
409
+ process.exit(0);
410
+ }
411
+
412
+ const launchCwd = process.cwd();
413
+ const launch = loadRepoLaunch();
414
+
415
+ if (opts.ui) {
416
+ await runUiMode(opts, launch, launchCwd);
417
+ return;
418
+ }
419
+
420
+ if (!launch) {
421
+ console.error(
422
+ 'No hay un repositorio git en el directorio actual.\nUsá --ui para abrir la interfaz, o corré diff-review desde un repo.'
423
+ );
424
+ process.exit(1);
425
+ }
426
+
427
+ const { branch, base, prompted } = await resolveBranchBase(launch, opts);
428
+
429
+ if (branch === base) {
430
+ // Ambos flags iguales / sin TTY: no se puede corregir en selects.
431
+ console.log(
432
+ `Branch y base son iguales (${branch}): el diff estaría vacío.\nAbro la UI para que elijas otra combinación.`
433
+ );
434
+ await startServer({
435
+ host: opts.host,
436
+ port: opts.port,
437
+ open: opts.open,
438
+ launchCwd,
439
+ openPath: '/'
440
+ });
441
+ return;
442
+ }
443
+
444
+ const outputFilename = buildOutputFilename(launch.repo, branch, base);
445
+
446
+ if (!prompted) {
447
+ console.log(`Repo: ${launch.repo}`);
448
+ console.log(`Branch: ${branch} (base: ${base})`);
449
+ } else {
450
+ p.note(`${branch} ← ${base}\n${outputFilename}`, 'Review');
451
+ }
452
+
453
+ const prompt = buildPrompt(
454
+ {
455
+ source: 'local',
456
+ repo: launch.repo,
457
+ branch,
458
+ base,
459
+ remoteUrl: launch.remoteUrl
460
+ },
461
+ { outputFilename }
462
+ );
463
+
464
+ const copied = copyPromptOrWarn(prompt);
465
+ explainWaitingForAgent(outputFilename, { copied, useClack: prompted });
466
+
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
+ });
473
+
474
+ console.log('JSON OK. Abriendo la UI web…');
475
+ await startServer({
476
+ host: opts.host,
477
+ port: opts.port,
478
+ open: opts.open,
479
+ launchCwd: launch.repo,
480
+ importFile: result.path,
481
+ openPath: '/?import=1'
482
+ });
483
+ }
484
+
485
+ main().catch((err) => {
486
+ console.error(err?.message || err);
487
+ process.exit(1);
488
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Autocomplete estilo Clack con etiquetas en español
3
+ * ("Buscar:" en lugar de "Search:").
4
+ */
5
+ import { AutocompletePrompt, settings } from '@clack/core';
6
+ import {
7
+ S_BAR,
8
+ S_BAR_END,
9
+ S_RADIO_ACTIVE,
10
+ S_RADIO_INACTIVE,
11
+ limitOptions,
12
+ symbol
13
+ } from '@clack/prompts';
14
+ import { styleText } from 'node:util';
15
+
16
+ function optionLabel(opt) {
17
+ return opt.label ?? String(opt.value ?? '');
18
+ }
19
+
20
+ function defaultFilter(search, option) {
21
+ const q = String(search || '').toLowerCase();
22
+ if (!q) return true;
23
+ const label = optionLabel(option).toLowerCase();
24
+ const value = String(option.value ?? '').toLowerCase();
25
+ const hint = String(option.hint ?? '').toLowerCase();
26
+ return label.includes(q) || value.includes(q) || hint.includes(q);
27
+ }
28
+
29
+ /**
30
+ * @template Value
31
+ * @param {{
32
+ * message: string,
33
+ * options: Array<{ value: Value, label?: string, hint?: string, disabled?: boolean }>,
34
+ * initialValue?: Value,
35
+ * maxItems?: number,
36
+ * placeholder?: string,
37
+ * }} opts
38
+ * @returns {Promise<Value | symbol>}
39
+ */
40
+ export function autocompleteEs(opts) {
41
+ return new AutocompletePrompt({
42
+ options: opts.options,
43
+ initialValue: opts.initialValue !== undefined ? [opts.initialValue] : undefined,
44
+ filter: defaultFilter,
45
+ render() {
46
+ const withGuide = settings.withGuide;
47
+ const title = withGuide
48
+ ? [`${styleText('gray', S_BAR)}`, `${symbol(this.state)} ${opts.message}`]
49
+ : [`${symbol(this.state)} ${opts.message}`];
50
+ const input = this.userInput;
51
+ const all = this.options;
52
+ const placeholder = opts.placeholder;
53
+ const showPlaceholder = input === '' && placeholder !== undefined;
54
+
55
+ const styleOption = (opt, kind) => {
56
+ const label = optionLabel(opt);
57
+ const hint =
58
+ opt.hint && opt.value === this.focusedValue ? styleText('dim', ` (${opt.hint})`) : '';
59
+ switch (kind) {
60
+ case 'active':
61
+ return `${styleText('green', S_RADIO_ACTIVE)} ${label}${hint}`;
62
+ case 'inactive':
63
+ return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`;
64
+ case 'disabled':
65
+ return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`;
66
+ default:
67
+ return label;
68
+ }
69
+ };
70
+
71
+ switch (this.state) {
72
+ case 'submit': {
73
+ const selected = this.selectedValues?.[0];
74
+ const label =
75
+ selected !== undefined
76
+ ? ` ${styleText('dim', optionLabel(all.find((o) => o.value === selected) || { value: selected }))}`
77
+ : '';
78
+ const bar = withGuide ? styleText('gray', S_BAR) : '';
79
+ return `${title.join('\n')}\n${bar}${label}`;
80
+ }
81
+ case 'cancel': {
82
+ const typed = input ? ` ${styleText(['strikethrough', 'dim'], input)}` : '';
83
+ const bar = withGuide ? styleText('gray', S_BAR) : '';
84
+ return `${title.join('\n')}\n${bar}${typed}`;
85
+ }
86
+ default: {
87
+ const color = this.state === 'error' ? 'yellow' : 'cyan';
88
+ const prefix = withGuide ? `${styleText(color, S_BAR)} ` : '';
89
+ const end = withGuide ? styleText(color, S_BAR_END) : '';
90
+
91
+ let field = '';
92
+ if (this.isNavigating || showPlaceholder) {
93
+ const shown = showPlaceholder ? placeholder : input;
94
+ field = shown !== '' ? ` ${styleText('dim', shown)}` : '';
95
+ } else {
96
+ field = ` ${this.userInputWithCursor}`;
97
+ }
98
+
99
+ const matchHint =
100
+ this.filteredOptions.length !== all.length
101
+ ? styleText(
102
+ 'dim',
103
+ ` (${this.filteredOptions.length} coincidencia${this.filteredOptions.length === 1 ? '' : 's'})`
104
+ )
105
+ : '';
106
+
107
+ const lines = [...title];
108
+ if (withGuide) lines.push(`${prefix.trimEnd()}`);
109
+ lines.push(`${prefix}${styleText('dim', 'Buscar:')}${field}${matchHint}`);
110
+
111
+ if (this.filteredOptions.length === 0 && input) {
112
+ lines.push(`${prefix}${styleText('yellow', 'Sin coincidencias')}`);
113
+ }
114
+ if (this.state === 'error') {
115
+ lines.push(`${prefix}${styleText('yellow', this.error)}`);
116
+ }
117
+
118
+ const hints = [
119
+ `${styleText('dim', '↑/↓')} elegir`,
120
+ `${styleText('dim', 'Enter')} confirmar`,
121
+ `${styleText('dim', 'Escribí')} para filtrar`
122
+ ];
123
+ const footer = [`${prefix}${hints.join(' · ')}`, end];
124
+
125
+ const options =
126
+ this.filteredOptions.length === 0
127
+ ? []
128
+ : limitOptions({
129
+ cursor: this.cursor,
130
+ options: this.filteredOptions,
131
+ columnPadding: withGuide ? 3 : 0,
132
+ rowPadding: lines.length + footer.length,
133
+ style: (opt, active) =>
134
+ styleOption(opt, opt.disabled ? 'disabled' : active ? 'active' : 'inactive'),
135
+ maxItems: opts.maxItems
136
+ });
137
+
138
+ return [...lines, ...options.map((row) => `${prefix}${row}`), ...footer].join('\n');
139
+ }
140
+ }
141
+ }
142
+ }).prompt();
143
+ }
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /** Copia texto al portapapeles del SO. Devuelve true si parece haber funcionado. */
4
+ export function copyTextToClipboard(text) {
5
+ const value = String(text ?? "");
6
+ try {
7
+ if (process.platform === "win32") {
8
+ const r = spawnSync("clip", [], {
9
+ input: value,
10
+ encoding: "utf8",
11
+ windowsHide: true,
12
+ });
13
+ return r.status === 0;
14
+ }
15
+ if (process.platform === "darwin") {
16
+ const r = spawnSync("pbcopy", [], { input: value, encoding: "utf8" });
17
+ return r.status === 0;
18
+ }
19
+ let r = spawnSync("wl-copy", [], { input: value, encoding: "utf8" });
20
+ if (r.status === 0) return true;
21
+ r = spawnSync("xclip", ["-selection", "clipboard"], {
22
+ input: value,
23
+ encoding: "utf8",
24
+ });
25
+ return r.status === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }