@jpkme/diff-review 0.1.6 → 0.1.10

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 +32 -1
  2. package/bin/diff-review.mjs +227 -18
  3. package/lib/agents.mjs +319 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +2 -1
  6. package/lib/check-report.test.mjs +1 -0
  7. package/lib/fingerprint.mjs +24 -0
  8. package/lib/git.mjs +2 -19
  9. package/lib/output-name.mjs +1 -1
  10. package/lib/prompt.mjs +19 -18
  11. package/lib/review-meta.mjs +33 -0
  12. package/lib/review-meta.test.mjs +30 -0
  13. package/lib/review.test.mjs +1 -0
  14. package/lib/validate.mjs +28 -11
  15. package/lib/validate.test.mjs +25 -0
  16. package/lib/wait-output.mjs +43 -17
  17. package/lib/wait-output.test.mjs +68 -0
  18. package/package.json +1 -1
  19. package/schema.json +9 -10
  20. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +1 -0
  21. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  23. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css → 3.PHTPLlZ7.css} +1 -1
  24. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  25. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css.gz → 3.PHTPLlZ7.css.gz} +0 -0
  26. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js +2 -0
  27. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  28. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  29. package/web/build/client/_app/immutable/chunks/{DRga3k74.js → QdZF3nkf.js} +35 -33
  30. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  31. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  32. package/web/build/client/_app/immutable/entry/{app.BP_R_kTF.js → app.Dce6uJU3.js} +2 -2
  33. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  34. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  35. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +1 -0
  36. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  37. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  38. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +1 -0
  39. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  40. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  41. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +66 -0
  42. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  43. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  44. package/web/build/client/_app/immutable/nodes/{3.Bn5l-P1e.js → 3.CeUs4lBX.js} +2 -2
  45. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  46. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  47. package/web/build/client/_app/version.json +1 -1
  48. package/web/build/client/_app/version.json.br +0 -0
  49. package/web/build/client/_app/version.json.gz +0 -0
  50. package/web/build/handler.js +3 -3
  51. package/web/build/index.js +3 -3
  52. package/web/build/server/chunks/1-2OO2bb7H.js +7 -0
  53. package/web/build/server/chunks/1-2OO2bb7H.js.map +1 -0
  54. package/web/build/server/chunks/2-BJvuqKN6.js +7 -0
  55. package/web/build/server/chunks/2-BJvuqKN6.js.map +1 -0
  56. package/web/build/server/chunks/3-ZIl1rHef.js +7 -0
  57. package/web/build/server/chunks/{3-DaeIJXI7.js.map → 3-ZIl1rHef.js.map} +1 -1
  58. package/web/build/server/chunks/{handler-2eew4UOl.js → handler-B5P65tLk.js} +3 -3
  59. package/web/build/server/chunks/{handler-2eew4UOl.js.map → handler-B5P65tLk.js.map} +1 -1
  60. package/web/build/server/chunks/{index.js-DbiNsix3.js → index.js-CL95e0Gl.js} +2 -2
  61. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +1 -0
  62. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js → manifest.js-uD_a0079.js} +6 -6
  63. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js.map → manifest.js-uD_a0079.js.map} +1 -1
  64. package/web/build/server/chunks/{remote-1g4ayag-DESnyPRn.js → remote-1g4ayag-B67QMR_2.js} +154 -149
  65. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +1 -0
  66. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  67. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  68. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  69. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  70. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js +0 -1
  71. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.br +0 -2
  72. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/DRga3k74.js.br +0 -0
  74. package/web/build/client/_app/immutable/chunks/DRga3k74.js.gz +0 -0
  75. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js +0 -2
  76. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.br +0 -0
  79. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.gz +0 -0
  80. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js +0 -1
  81. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.br +0 -1
  82. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.gz +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js +0 -1
  84. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.br +0 -3
  85. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js +0 -66
  87. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.br +0 -0
  90. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.gz +0 -0
  91. package/web/build/server/chunks/1-CRg9pswO.js +0 -7
  92. package/web/build/server/chunks/1-CRg9pswO.js.map +0 -1
  93. package/web/build/server/chunks/2-BmYzresl.js +0 -7
  94. package/web/build/server/chunks/2-BmYzresl.js.map +0 -1
  95. package/web/build/server/chunks/3-DaeIJXI7.js +0 -7
  96. package/web/build/server/chunks/index.js-DbiNsix3.js.map +0 -1
  97. package/web/build/server/chunks/remote-1g4ayag-DESnyPRn.js.map +0 -1
package/lib/agents.mjs ADDED
@@ -0,0 +1,319 @@
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
+ /**
279
+ * Id de modelo que realmente se usó: el elegido, o el configurado del agente si no se pasó `--model`.
280
+ * @param {AgentDef} def
281
+ * @param {string} [model]
282
+ */
283
+ export function resolvedRunModel(def, model) {
284
+ const chosen = String(model || "").trim();
285
+ if (chosen) return chosen;
286
+ try {
287
+ return def.configuredModel?.(context())?.model || "";
288
+ } catch {
289
+ return "";
290
+ }
291
+ }
292
+
293
+ /** Comillas para cmd.exe (solo hace falta con los shims .cmd/.bat de npm). */
294
+ function quoteForCmd(arg) {
295
+ return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
296
+ }
297
+
298
+ /**
299
+ * Lanza el agente en el repo con el prompt por stdin; stdout/stderr van a la terminal.
300
+ * @param {DetectedAgent} agent
301
+ * @param {{ cwd: string, prompt: string } & RunSpec} spec
302
+ * @returns {{ child: import('node:child_process').ChildProcess, exited: Promise<number> }}
303
+ */
304
+ export function runAgent(agent, { cwd, prompt, model, outputFilename }) {
305
+ const args = agent.def.args({ model, outputFilename });
306
+ // Node no lanza .cmd/.bat sin shell (CVE-2024-27980); con shell hay que citar a mano.
307
+ const viaShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(agent.path);
308
+ const child = viaShell
309
+ ? spawn([agent.path, ...args].map(quoteForCmd).join(" "), { cwd, shell: true, stdio: ["pipe", "inherit", "inherit"] })
310
+ : spawn(agent.path, args, { cwd, stdio: ["pipe", "inherit", "inherit"] });
311
+ // Si el agente sale antes de leer stdin (p. ej. un modelo inválido), el EPIPE no debe tirar abajo la CLI.
312
+ child.stdin?.on("error", () => {});
313
+ child.stdin?.end(prompt);
314
+ const exited = new Promise((resolve, reject) => {
315
+ child.once("error", reject);
316
+ child.once("exit", (code, signal) => resolve(signal ? 1 : (code ?? 1)));
317
+ });
318
+ return { child, exited };
319
+ }
@@ -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
+ });
@@ -159,7 +159,8 @@ export function formatCheckReport(file, command, result) {
159
159
  "How to continue:",
160
160
  "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.",
161
161
  '- Every diff path must appear in "blocks" or "skipped" (a skipped glob counts).',
162
- "- what/why/fix stay ≤120 chars; intent ≥40 chars; enums exactly as in the schema.",
162
+ "- meta.agent and meta.model are required (product name and the exact model id you are running).",
163
+ "- intent ≥40 chars; enums exactly as in the schema.",
163
164
  "- Human-readable strings in Spanish.",
164
165
  `- Re-run exactly: \`${command}\``,
165
166
  "- Stop only when that command prints OK."
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { checkPayload, formatCheckReport } from "./check-report.mjs";
4
4
 
5
5
  const valid = () => ({
6
+ meta: { agent: "Cursor", model: "composer" },
6
7
  intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
7
8
  groups: [{ id: "g1", kind: "feat", title: "CRUD de depósitos", intent: "base de todo" }],
8
9
  blocks: [
@@ -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";