@jpkme/diff-review 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +32 -1
  2. package/bin/diff-review.mjs +221 -17
  3. package/lib/agents.mjs +304 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/fingerprint.mjs +24 -0
  6. package/lib/git.mjs +2 -19
  7. package/lib/output-name.mjs +1 -1
  8. package/lib/prompt.mjs +6 -6
  9. package/lib/wait-output.mjs +43 -17
  10. package/lib/wait-output.test.mjs +67 -0
  11. package/package.json +1 -1
  12. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +1 -0
  13. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  14. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  15. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +2 -0
  16. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  17. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  18. package/web/build/client/_app/immutable/chunks/{DRga3k74.js → D8sAxZVo.js} +20 -20
  19. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  20. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  21. package/web/build/client/_app/immutable/entry/{app.BP_R_kTF.js → app.Dl9OTE-n.js} +2 -2
  22. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  23. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  24. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +1 -0
  25. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +1 -0
  26. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  27. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +1 -0
  28. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  29. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  30. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +66 -0
  31. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  32. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  33. package/web/build/client/_app/immutable/nodes/{3.Bn5l-P1e.js → 3.C-USexyX.js} +2 -2
  34. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  35. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  36. package/web/build/client/_app/version.json +1 -1
  37. package/web/build/client/_app/version.json.br +0 -0
  38. package/web/build/client/_app/version.json.gz +0 -0
  39. package/web/build/handler.js +3 -3
  40. package/web/build/index.js +3 -3
  41. package/web/build/server/chunks/1-0X6Jp9nH.js +7 -0
  42. package/web/build/server/chunks/1-0X6Jp9nH.js.map +1 -0
  43. package/web/build/server/chunks/2-HlcVALg6.js +7 -0
  44. package/web/build/server/chunks/2-HlcVALg6.js.map +1 -0
  45. package/web/build/server/chunks/{3-DaeIJXI7.js → 3-BN1eS7L2.js} +2 -2
  46. package/web/build/server/chunks/{3-DaeIJXI7.js.map → 3-BN1eS7L2.js.map} +1 -1
  47. package/web/build/server/chunks/{handler-2eew4UOl.js → handler-cq3QoEzW.js} +3 -3
  48. package/web/build/server/chunks/{handler-2eew4UOl.js.map → handler-cq3QoEzW.js.map} +1 -1
  49. package/web/build/server/chunks/{index.js-DbiNsix3.js → index.js-D4qkHe3r.js} +2 -2
  50. package/web/build/server/chunks/{index.js-DbiNsix3.js.map → index.js-D4qkHe3r.js.map} +1 -1
  51. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js → manifest.js-DQHMKyJh.js} +6 -6
  52. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js.map → manifest.js-DQHMKyJh.js.map} +1 -1
  53. package/web/build/server/chunks/{remote-1g4ayag-DESnyPRn.js → remote-1g4ayag-B8KZPTkn.js} +154 -149
  54. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +1 -0
  55. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  56. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  57. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  58. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js +0 -1
  59. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.br +0 -2
  60. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.gz +0 -0
  61. package/web/build/client/_app/immutable/chunks/DRga3k74.js.br +0 -0
  62. package/web/build/client/_app/immutable/chunks/DRga3k74.js.gz +0 -0
  63. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js +0 -2
  64. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.br +0 -0
  65. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.gz +0 -0
  66. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.br +0 -0
  67. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js +0 -1
  69. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.br +0 -1
  70. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.gz +0 -0
  71. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js +0 -1
  72. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.br +0 -3
  73. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.gz +0 -0
  74. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js +0 -66
  75. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.br +0 -0
  76. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.gz +0 -0
  77. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.br +0 -0
  78. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.gz +0 -0
  79. package/web/build/server/chunks/1-CRg9pswO.js +0 -7
  80. package/web/build/server/chunks/1-CRg9pswO.js.map +0 -1
  81. package/web/build/server/chunks/2-BmYzresl.js +0 -7
  82. package/web/build/server/chunks/2-BmYzresl.js.map +0 -1
  83. package/web/build/server/chunks/remote-1g4ayag-DESnyPRn.js.map +0 -1
package/lib/agents.mjs ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Agentes de código que la CLI puede lanzar con el prompt (`--agent`).
3
+ *
4
+ * No hay un registro de agentes en ningún SO: se detectan por nombre en el PATH más algunas rutas fijas
5
+ * conocidas (p. ej. el Claude Code que trae la app de escritorio en Windows). El prompt viaja por stdin, así
6
+ * no se toca el portapapeles ni se choca con el límite de largo de la línea de comandos de los shims `.cmd`.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { delimiter, join } from "node:path";
12
+
13
+ /**
14
+ * @typedef {{ value: string, label: string, hint?: string }} ModelOption
15
+ * @typedef {{ model?: string, outputFilename: string }} RunSpec
16
+ * @typedef {{
17
+ * id: string,
18
+ * label: string,
19
+ * bins: string[],
20
+ * extraPaths?: (ctx: DetectContext) => string[],
21
+ * models: ModelOption[],
22
+ * configuredModel?: (ctx: DetectContext) => { model: string, source: string } | null,
23
+ * args: (spec: RunSpec) => string[],
24
+ * }} AgentDef
25
+ * @typedef {{ env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string }} DetectContext
26
+ * @typedef {{ def: AgentDef, path: string }} DetectedAgent
27
+ */
28
+
29
+ /** @param {string} file */
30
+ function readJson(file) {
31
+ try {
32
+ return JSON.parse(readFileSync(file, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Primer `model = "..."` de nivel raíz de un TOML (antes de cualquier `[sección]`). */
39
+ export function topLevelTomlModel(text) {
40
+ for (const line of String(text).split(/\r?\n/)) {
41
+ if (/^\s*\[/.test(line)) break;
42
+ const m = line.match(/^\s*model\s*=\s*["']([^"']+)["']/);
43
+ if (m) return m[1];
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** Versiones tipo `2.1.286` de mayor a menor (las que no parsean, al final). */
49
+ function byVersionDesc(a, b) {
50
+ const pa = a.split(".").map(Number);
51
+ const pb = b.split(".").map(Number);
52
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
53
+ const d = (pb[i] || 0) - (pa[i] || 0);
54
+ if (d) return Number.isNaN(d) ? 0 : d;
55
+ }
56
+ return 0;
57
+ }
58
+
59
+ /** `<root>/<versión>/<hash>/<exe>` → rutas, de la versión más nueva a la más vieja. */
60
+ function versionedBinaries(root, exe) {
61
+ /** @type {string[]} */
62
+ const out = [];
63
+ let versions;
64
+ try {
65
+ versions = readdirSync(root).sort(byVersionDesc);
66
+ } catch {
67
+ return out;
68
+ }
69
+ for (const version of versions) {
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(join(root, version));
73
+ } catch {
74
+ continue;
75
+ }
76
+ for (const entry of entries) out.push(join(root, version, entry, exe));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /** @type {AgentDef[]} */
82
+ export const AGENTS = [
83
+ {
84
+ id: "claude",
85
+ label: "Claude Code",
86
+ bins: ["claude"],
87
+ extraPaths: ({ env, platform, home }) => {
88
+ if (platform === "win32") {
89
+ // La app de escritorio trae su propio Claude Code, fuera del PATH.
90
+ const appData = env.APPDATA || join(home, "AppData", "Roaming");
91
+ return versionedBinaries(join(appData, "Claude", "claude-code"), "claude.exe");
92
+ }
93
+ const paths = [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
94
+ if (platform === "darwin") {
95
+ paths.push(...versionedBinaries(join(home, "Library", "Application Support", "Claude", "claude-code"), "claude"));
96
+ }
97
+ return paths;
98
+ },
99
+ // Alias estables del CLI: siempre apuntan al último modelo de cada familia.
100
+ models: [
101
+ { value: "opus", label: "opus", hint: "el más capaz" },
102
+ { value: "sonnet", label: "sonnet", hint: "equilibrado" },
103
+ { value: "haiku", label: "haiku", hint: "el más rápido" },
104
+ ],
105
+ configuredModel: ({ env, home }) => {
106
+ if (env.ANTHROPIC_MODEL) return { model: env.ANTHROPIC_MODEL, source: "ANTHROPIC_MODEL" };
107
+ const file = join(home, ".claude", "settings.json");
108
+ const model = readJson(file)?.model;
109
+ return typeof model === "string" && model ? { model, source: "~/.claude/settings.json" } : null;
110
+ },
111
+ args: ({ model, outputFilename }) => [
112
+ "-p",
113
+ ...(model ? ["--model", model] : []),
114
+ // Solo lectura + git + el validate, y escribir únicamente el JSON del review (Edit cubre Write).
115
+ "--allowedTools",
116
+ "Read",
117
+ "Grep",
118
+ "Glob",
119
+ "Bash(git:*)",
120
+ "Bash(npx --yes @jpkme/diff-review validate:*)",
121
+ `Edit(./${outputFilename})`,
122
+ ],
123
+ },
124
+ {
125
+ id: "codex",
126
+ label: "Codex",
127
+ bins: ["codex"],
128
+ models: [],
129
+ configuredModel: ({ home }) => {
130
+ try {
131
+ const model = topLevelTomlModel(readFileSync(join(home, ".codex", "config.toml"), "utf8"));
132
+ return model ? { model, source: "~/.codex/config.toml" } : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ },
137
+ // `-` = prompt por stdin; workspace-write deja escribir el JSON en el repo.
138
+ args: ({ model }) => ["exec", "--sandbox", "workspace-write", ...(model ? ["-m", model] : []), "-"],
139
+ },
140
+ {
141
+ id: "cursor",
142
+ label: "Cursor CLI",
143
+ // No `agent` (el alias nuevo): es un nombre demasiado genérico en el PATH. Tampoco `cursor`, que abre el IDE.
144
+ bins: ["cursor-agent"],
145
+ extraPaths: ({ env, platform, home }) =>
146
+ platform === "win32"
147
+ ? [join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "cursor-agent", "cursor-agent.cmd")]
148
+ : [join(home, ".local", "bin", "cursor-agent")],
149
+ models: [],
150
+ configuredModel: ({ home }) => {
151
+ const config = readJson(join(home, ".cursor", "cli-config.json"));
152
+ const id = config?.selectedModel?.modelId ?? config?.model?.modelId;
153
+ if (typeof id !== "string" || !id) return null;
154
+ // "default" es Auto: Cursor elige el modelo.
155
+ return { model: id === "default" ? "auto" : id, source: "~/.cursor/cli-config.json" };
156
+ },
157
+ // Con -p lee el prompt de stdin. Sin --force solo corre lo que esté en la allowlist del usuario
158
+ // (`~/.cursor/cli-config.json`) y bloquearía el validate; no hay flag para pasar una allowlist.
159
+ args: ({ model }) => ["-p", "--output-format", "text", "--trust", "--force", ...(model ? ["--model", model] : [])],
160
+ },
161
+ {
162
+ id: "copilot",
163
+ label: "GitHub Copilot CLI",
164
+ bins: ["copilot"],
165
+ models: [{ value: "auto", label: "auto", hint: "Copilot elige" }],
166
+ configuredModel: ({ env, home }) => {
167
+ const dir = env.COPILOT_HOME || join(home, ".copilot");
168
+ const model = readJson(join(dir, "settings.json"))?.model;
169
+ const source = env.COPILOT_HOME ? "$COPILOT_HOME/settings.json" : "~/.copilot/settings.json";
170
+ return typeof model === "string" && model ? { model, source } : null;
171
+ },
172
+ // Prompt por stdin (sin -p). Lo que no está permitido se rechaza sin preguntar: git, npx (las reglas de shell
173
+ // matchean por comando, no por argumentos, así que no se puede acotar al validate) y escribir solo el JSON
174
+ // del review (leer no pide permiso).
175
+ args: ({ model, outputFilename }) => [
176
+ "-s",
177
+ ...(model ? ["--model", model] : []),
178
+ "--allow-tool",
179
+ "shell(git:*)",
180
+ "--allow-tool",
181
+ "shell(npx:*)",
182
+ "--allow-tool",
183
+ `write(${outputFilename})`,
184
+ ],
185
+ },
186
+ {
187
+ id: "gemini",
188
+ label: "Gemini CLI",
189
+ bins: ["gemini"],
190
+ models: [],
191
+ configuredModel: ({ home }) => {
192
+ const model = readJson(join(home, ".gemini", "settings.json"))?.model;
193
+ const name = typeof model === "string" ? model : model?.name;
194
+ return typeof name === "string" && name ? { model: name, source: "~/.gemini/settings.json" } : null;
195
+ },
196
+ // Sin TTY lee el prompt de stdin; yolo porque no hay quién apruebe los comandos.
197
+ args: ({ model }) => [...(model ? ["-m", model] : []), "--yolo"],
198
+ },
199
+ ];
200
+
201
+ /** @param {string} file @param {NodeJS.Platform} platform */
202
+ function isExecutable(file, platform) {
203
+ try {
204
+ if (!statSync(file).isFile()) return false;
205
+ if (platform !== "win32") accessSync(file, constants.X_OK);
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /** Busca `name` en el PATH (con las extensiones de PATHEXT en Windows). */
213
+ export function findOnPath(name, { env, platform }) {
214
+ const dirs = String(env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
215
+ const exts =
216
+ platform === "win32"
217
+ ? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
218
+ : [""];
219
+ for (const dir of dirs) {
220
+ for (const ext of exts) {
221
+ const candidate = join(dir, name + ext.toLowerCase());
222
+ if (isExecutable(candidate, platform)) return candidate;
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** @param {Partial<DetectContext>} [ctx] */
229
+ function context(ctx = {}) {
230
+ return { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, home: ctx.home ?? homedir() };
231
+ }
232
+
233
+ /**
234
+ * Agentes instalados, en el orden de `AGENTS`. Primero el PATH, después las rutas fijas.
235
+ * @param {Partial<DetectContext>} [ctx]
236
+ * @returns {DetectedAgent[]}
237
+ */
238
+ export function detectAgents(ctx) {
239
+ const c = context(ctx);
240
+ /** @type {DetectedAgent[]} */
241
+ const found = [];
242
+ for (const def of AGENTS) {
243
+ let path = null;
244
+ for (const bin of def.bins) {
245
+ path = findOnPath(bin, c);
246
+ if (path) break;
247
+ }
248
+ if (!path) path = (def.extraPaths?.(c) ?? []).find((p) => existsSync(p) && isExecutable(p, c.platform)) ?? null;
249
+ if (path) found.push({ def, path });
250
+ }
251
+ return found;
252
+ }
253
+
254
+ /** Valor del select para "no pasar --model" (que el agente use el suyo). */
255
+ export const AGENT_DEFAULT_MODEL = "";
256
+ /** Valor del select para escribir un id de modelo a mano. */
257
+ export const OTHER_MODEL = "__other__";
258
+
259
+ /**
260
+ * Opciones del select de modelo: primero el default del agente (marcado como inicial), después los conocidos y
261
+ * "Otro…". Si el default configurado coincide con uno conocido, no se repite.
262
+ * @param {AgentDef} def
263
+ * @param {Partial<DetectContext>} [ctx]
264
+ * @returns {{ options: ModelOption[], initialValue: string }}
265
+ */
266
+ export function modelChoices(def, ctx) {
267
+ const configured = def.configuredModel?.(context(ctx)) ?? null;
268
+ const first = configured
269
+ ? { value: AGENT_DEFAULT_MODEL, label: `Por defecto (${configured.model})`, hint: configured.source }
270
+ : { value: AGENT_DEFAULT_MODEL, label: "Por defecto del agente", hint: "no se pasa --model" };
271
+ const rest = def.models.filter((m) => m.value !== configured?.model);
272
+ return {
273
+ options: [first, ...rest, { value: OTHER_MODEL, label: "Otro…", hint: "escribir el id del modelo" }],
274
+ initialValue: AGENT_DEFAULT_MODEL,
275
+ };
276
+ }
277
+
278
+ /** Comillas para cmd.exe (solo hace falta con los shims .cmd/.bat de npm). */
279
+ function quoteForCmd(arg) {
280
+ return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
281
+ }
282
+
283
+ /**
284
+ * Lanza el agente en el repo con el prompt por stdin; stdout/stderr van a la terminal.
285
+ * @param {DetectedAgent} agent
286
+ * @param {{ cwd: string, prompt: string } & RunSpec} spec
287
+ * @returns {{ child: import('node:child_process').ChildProcess, exited: Promise<number> }}
288
+ */
289
+ export function runAgent(agent, { cwd, prompt, model, outputFilename }) {
290
+ const args = agent.def.args({ model, outputFilename });
291
+ // Node no lanza .cmd/.bat sin shell (CVE-2024-27980); con shell hay que citar a mano.
292
+ const viaShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(agent.path);
293
+ const child = viaShell
294
+ ? spawn([agent.path, ...args].map(quoteForCmd).join(" "), { cwd, shell: true, stdio: ["pipe", "inherit", "inherit"] })
295
+ : spawn(agent.path, args, { cwd, stdio: ["pipe", "inherit", "inherit"] });
296
+ // Si el agente sale antes de leer stdin (p. ej. un modelo inválido), el EPIPE no debe tirar abajo la CLI.
297
+ child.stdin?.on("error", () => {});
298
+ child.stdin?.end(prompt);
299
+ const exited = new Promise((resolve, reject) => {
300
+ child.once("error", reject);
301
+ child.once("exit", (code, signal) => resolve(signal ? 1 : (code ?? 1)));
302
+ });
303
+ return { child, exited };
304
+ }
@@ -0,0 +1,195 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import {
7
+ AGENT_DEFAULT_MODEL,
8
+ AGENTS,
9
+ detectAgents,
10
+ findOnPath,
11
+ modelChoices,
12
+ OTHER_MODEL,
13
+ topLevelTomlModel,
14
+ } from "./agents.mjs";
15
+
16
+ const claude = AGENTS.find((a) => a.id === "claude");
17
+ const codex = AGENTS.find((a) => a.id === "codex");
18
+ const cursor = AGENTS.find((a) => a.id === "cursor");
19
+ const copilot = AGENTS.find((a) => a.id === "copilot");
20
+
21
+ function sandbox() {
22
+ return mkdtempSync(join(tmpdir(), "diff-review-agents-"));
23
+ }
24
+
25
+ function touch(file, content = "") {
26
+ mkdirSync(join(file, ".."), { recursive: true });
27
+ writeFileSync(file, content, { mode: 0o755 });
28
+ return file;
29
+ }
30
+
31
+ describe("findOnPath", () => {
32
+ it("en Windows prueba las extensiones de PATHEXT (shims .cmd de npm)", () => {
33
+ const root = sandbox();
34
+ const bin = join(root, "npm");
35
+ touch(join(bin, "codex.cmd"));
36
+ const found = findOnPath("codex", {
37
+ env: { PATH: `${join(root, "nada")};${bin}`, PATHEXT: ".EXE;.CMD" },
38
+ platform: "win32",
39
+ });
40
+ assert.equal(found, join(bin, "codex.cmd"));
41
+ });
42
+
43
+ it("devuelve null si no está", () => {
44
+ assert.equal(findOnPath("codex", { env: { PATH: sandbox() }, platform: "linux" }), null);
45
+ });
46
+ });
47
+
48
+ describe("detectAgents", () => {
49
+ it("encuentra el Claude Code de la app de escritorio de Windows, en su versión más nueva", () => {
50
+ const home = sandbox();
51
+ const appData = join(home, "AppData", "Roaming");
52
+ const root = join(appData, "Claude", "claude-code");
53
+ touch(join(root, "2.1.9", "aaa", "claude.exe"));
54
+ const newest = touch(join(root, "2.1.286", "bbb", "claude.exe"));
55
+ const found = detectAgents({ env: { PATH: "", APPDATA: appData }, platform: "win32", home });
56
+ assert.deepEqual(
57
+ found.map((a) => [a.def.id, a.path]),
58
+ [["claude", newest]]
59
+ );
60
+ });
61
+
62
+ it("el PATH gana sobre las rutas fijas", () => {
63
+ const home = sandbox();
64
+ const appData = join(home, "AppData", "Roaming");
65
+ touch(join(appData, "Claude", "claude-code", "2.1.286", "bbb", "claude.exe"));
66
+ const onPath = touch(join(home, "bin", "claude.exe"));
67
+ const found = detectAgents({
68
+ env: { PATH: join(home, "bin"), PATHEXT: ".EXE", APPDATA: appData },
69
+ platform: "win32",
70
+ home,
71
+ });
72
+ assert.equal(found[0].path, onPath);
73
+ });
74
+
75
+ it("sin nada instalado devuelve vacío", () => {
76
+ assert.deepEqual(detectAgents({ env: { PATH: "" }, platform: "linux", home: sandbox() }), []);
77
+ });
78
+ });
79
+
80
+ describe("modelChoices", () => {
81
+ it("sin modelo configurado: 'Por defecto del agente' primero y como inicial, y 'Otro…' al final", () => {
82
+ const { options, initialValue } = modelChoices(claude, { env: {}, platform: "linux", home: sandbox() });
83
+ assert.equal(initialValue, AGENT_DEFAULT_MODEL);
84
+ assert.equal(options[0].value, AGENT_DEFAULT_MODEL);
85
+ assert.equal(options[0].label, "Por defecto del agente");
86
+ assert.deepEqual(
87
+ options.slice(1, -1).map((o) => o.value),
88
+ ["opus", "sonnet", "haiku"]
89
+ );
90
+ assert.equal(options.at(-1).value, OTHER_MODEL);
91
+ });
92
+
93
+ it("muestra el modelo configurado en ~/.claude/settings.json y no lo repite en la lista", () => {
94
+ const home = sandbox();
95
+ touch(join(home, ".claude", "settings.json"), JSON.stringify({ model: "sonnet" }));
96
+ const { options } = modelChoices(claude, { env: {}, platform: "linux", home });
97
+ assert.equal(options[0].label, "Por defecto (sonnet)");
98
+ assert.equal(options[0].hint, "~/.claude/settings.json");
99
+ assert.ok(!options.slice(1).some((o) => o.value === "sonnet"));
100
+ });
101
+
102
+ it("ANTHROPIC_MODEL pisa a settings.json", () => {
103
+ const { options } = modelChoices(claude, {
104
+ env: { ANTHROPIC_MODEL: "opus" },
105
+ platform: "linux",
106
+ home: sandbox(),
107
+ });
108
+ assert.equal(options[0].label, "Por defecto (opus)");
109
+ });
110
+
111
+ it("codex lee el model de nivel raíz de ~/.codex/config.toml", () => {
112
+ const home = sandbox();
113
+ touch(join(home, ".codex", "config.toml"), 'model = "gpt-x"\n[profiles.otro]\nmodel = "nope"\n');
114
+ const { options } = modelChoices(codex, { env: {}, platform: "linux", home });
115
+ assert.equal(options[0].label, "Por defecto (gpt-x)");
116
+ });
117
+ });
118
+
119
+ describe("topLevelTomlModel", () => {
120
+ it("ignora los model dentro de secciones", () => {
121
+ assert.equal(topLevelTomlModel('[profiles.a]\nmodel = "x"\n'), null);
122
+ assert.equal(topLevelTomlModel('approval = "never"\nmodel = \'y\'\n'), "y");
123
+ });
124
+ });
125
+
126
+ describe("args", () => {
127
+ it("claude: -p, --model solo si se eligió uno, y escritura limitada al JSON del review", () => {
128
+ const args = claude.args({ model: "opus", outputFilename: "diff-rev_a_b.json" });
129
+ assert.deepEqual(args.slice(0, 3), ["-p", "--model", "opus"]);
130
+ assert.ok(args.includes("Edit(./diff-rev_a_b.json)"));
131
+ assert.ok(!claude.args({ outputFilename: "x.json" }).includes("--model"));
132
+ });
133
+
134
+ it("codex: lee el prompt de stdin", () => {
135
+ assert.equal(codex.args({ outputFilename: "x.json" }).at(-1), "-");
136
+ });
137
+ });
138
+
139
+ describe("cursor", () => {
140
+ it("encuentra cursor-agent.cmd en %LOCALAPPDATA% aunque no esté en el PATH", () => {
141
+ const home = sandbox();
142
+ const localAppData = join(home, "AppData", "Local");
143
+ const shim = touch(join(localAppData, "cursor-agent", "cursor-agent.cmd"));
144
+ const found = detectAgents({ env: { PATH: "", LOCALAPPDATA: localAppData }, platform: "win32", home });
145
+ assert.deepEqual(
146
+ found.map((a) => [a.def.id, a.path]),
147
+ [["cursor", shim]]
148
+ );
149
+ });
150
+
151
+ it("no toma el alias genérico `agent` del PATH", () => {
152
+ const home = sandbox();
153
+ touch(join(home, "bin", "agent"));
154
+ assert.deepEqual(detectAgents({ env: { PATH: join(home, "bin") }, platform: "linux", home }), []);
155
+ });
156
+
157
+ it("lee el modelo de ~/.cursor/cli-config.json y muestra Auto como `auto`", () => {
158
+ const home = sandbox();
159
+ const file = join(home, ".cursor", "cli-config.json");
160
+ touch(file, JSON.stringify({ selectedModel: { modelId: "default" } }));
161
+ assert.equal(modelChoices(cursor, { env: {}, platform: "linux", home }).options[0].label, "Por defecto (auto)");
162
+ touch(file, JSON.stringify({ selectedModel: { modelId: "gpt-5" } }));
163
+ assert.equal(modelChoices(cursor, { env: {}, platform: "linux", home }).options[0].label, "Por defecto (gpt-5)");
164
+ });
165
+
166
+ it("args: print mode con --force y --model solo si se eligió uno", () => {
167
+ const args = cursor.args({ model: "sonnet-4", outputFilename: "x.json" });
168
+ assert.equal(args[0], "-p");
169
+ assert.ok(args.includes("--force") && args.includes("--trust"));
170
+ assert.deepEqual(args.slice(-2), ["--model", "sonnet-4"]);
171
+ assert.ok(!cursor.args({ outputFilename: "x.json" }).includes("--model"));
172
+ });
173
+ });
174
+
175
+ describe("copilot", () => {
176
+ it("lee el modelo de ~/.copilot/settings.json, o de $COPILOT_HOME", () => {
177
+ const home = sandbox();
178
+ touch(join(home, ".copilot", "settings.json"), JSON.stringify({ model: "gpt-5.4" }));
179
+ assert.equal(modelChoices(copilot, { env: {}, platform: "linux", home }).options[0].label, "Por defecto (gpt-5.4)");
180
+
181
+ const custom = sandbox();
182
+ touch(join(custom, "settings.json"), JSON.stringify({ model: "claude-haiku-4.5" }));
183
+ const { options } = modelChoices(copilot, { env: { COPILOT_HOME: custom }, platform: "linux", home });
184
+ assert.equal(options[0].label, "Por defecto (claude-haiku-4.5)");
185
+ assert.equal(options[0].hint, "$COPILOT_HOME/settings.json");
186
+ });
187
+
188
+ it("args: prompt por stdin (sin -p) y escritura limitada al JSON del review", () => {
189
+ const args = copilot.args({ model: "auto", outputFilename: "diff-rev_a_b.json" });
190
+ assert.ok(!args.includes("-p"));
191
+ assert.deepEqual(args.slice(0, 3), ["-s", "--model", "auto"]);
192
+ assert.ok(args.includes("write(diff-rev_a_b.json)"));
193
+ assert.ok(!args.includes("--allow-all-tools"));
194
+ });
195
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tips cortos de SHA. Sin APIs de Node: la UI lo importa para armar el nombre del JSON.
3
+ */
4
+
5
+ export function shortSha(sha, n = 10) {
6
+ const hex = String(sha || "")
7
+ .toLowerCase()
8
+ .replace(/[^a-f0-9]/g, "");
9
+ return hex.length >= 7 ? hex.slice(0, n) : "";
10
+ }
11
+
12
+ export function fingerprint(branchSha, baseSha) {
13
+ const a = shortSha(branchSha);
14
+ const b = shortSha(baseSha);
15
+ if (!a || !b) return null;
16
+ return `${a}-${b}`;
17
+ }
18
+
19
+ /** File/URL id: short tip of the reviewed branch + short tip of the base. */
20
+ export function reportId(branchSha, baseSha) {
21
+ const id = fingerprint(branchSha, baseSha);
22
+ if (!id) throw new Error("Faltan SHAs de los tips para el id del reporte.");
23
+ return id;
24
+ }
package/lib/git.mjs CHANGED
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
+ export { fingerprint, reportId } from "./fingerprint.mjs";
6
+
5
7
  export function assertGitRepo(repo) {
6
8
  const raw = String(repo || "").trim();
7
9
  if (!raw) {
@@ -74,25 +76,6 @@ export function branchTips(repo, branch, base) {
74
76
  };
75
77
  }
76
78
 
77
- export function fingerprint(branchSha, baseSha) {
78
- const a = shortSha(branchSha);
79
- const b = shortSha(baseSha);
80
- if (!a || !b) return null;
81
- return `${a}-${b}`;
82
- }
83
-
84
- /** File/URL id: short tip of the reviewed branch + short tip of the base. */
85
- export function reportId(branchSha, baseSha) {
86
- const id = fingerprint(branchSha, baseSha);
87
- if (!id) throw new Error("Faltan SHAs de los tips para el id del reporte.");
88
- return id;
89
- }
90
-
91
- function shortSha(sha, n = 10) {
92
- const hex = String(sha || "").toLowerCase().replace(/[^a-f0-9]/g, "");
93
- return hex.length >= 7 ? hex.slice(0, n) : "";
94
- }
95
-
96
79
  export function commitLog(repo, mergeBase, branch, limit = 25) {
97
80
  try {
98
81
  return git(repo, [
@@ -2,7 +2,7 @@
2
2
  * Nombre del JSON que escribe el agente.
3
3
  * Preferido: tips cortos branch/base. Legacy (con stamp o sin tips) sigue válido al importar.
4
4
  */
5
- import { fingerprint } from "./git.mjs";
5
+ import { fingerprint } from "./fingerprint.mjs";
6
6
 
7
7
  /** Prefijo del formato actual. */
8
8
  export const OUTPUT_PREFIX = "diff-rev";
package/lib/prompt.mjs CHANGED
@@ -23,7 +23,7 @@ export const OUTPUT_SCHEMA_BLOCK = `{
23
23
  "remoteUrl": "<origin URL; optional when source=url>",
24
24
  "generatedAt": "<ISO-8601 datetime; optional, informational>",
25
25
  "agent": "<tool name e.g. Claude Code / Cursor; optional, informational>",
26
- "model": "<model id e.g. claude-opus-…; optional, informational>"
26
+ "model": "<model id e.g. claude-opus; optional, informational>"
27
27
  },
28
28
  "intent": "string",
29
29
  "groups": [
@@ -71,10 +71,10 @@ export const OUTPUT_SCHEMA_BLOCK = `{
71
71
 
72
72
  const RULES = `- Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
73
73
  - Do NOT invent files, hunks, or line ranges that are not in the diff.
74
- - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span — not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
74
+ - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span, not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
75
75
  - Do NOT report: pre-existing code outside the diff; linter/formatter noise; naming/style prefs with no real consequence; intentional branch behavior; silenced lint rules; "missing docs/coverage" without a concrete broken scenario.
76
76
  - Quality findings must name a concrete cost, not a vague feeling.
77
- - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high → nit). Use "nit" sparingly.
77
+ - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
78
78
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
79
79
  - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
80
80
 
@@ -82,7 +82,7 @@ function workflowLocal(base, branch) {
82
82
  return `Workflow:
83
83
  1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
84
84
  2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
85
- 3. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
85
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
86
86
  4. Emit the JSON file. Do not modify the repo.
87
87
  5. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
88
88
  }
@@ -98,7 +98,7 @@ function workflowUrl(base, branch) {
98
98
  1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
99
99
  2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
100
100
  3. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
101
- 4. Read enough surrounding code (types, callers, tests) to judge behavior — not only the hunk lines.
101
+ 4. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
102
102
  5. Emit the JSON file.
103
103
  6. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
104
104
  }
@@ -162,7 +162,7 @@ ${outputSchemaBlock(meta)}
162
162
 
163
163
  Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). Also set informational fields: "generatedAt" (ISO-8601 now), "agent" (tool name), "model" (model id). That lets someone reopen the report elsewhere without re-picking the repo.
164
164
 
165
- Do NOT include diffs or a "files" array — the tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
165
+ Do NOT include diffs or a "files" array. The tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
166
166
 
167
167
  After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
168
168