@jpkme/diff-review 0.1.5 → 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 (85) hide show
  1. package/README.md +33 -2
  2. package/bin/diff-review.mjs +230 -18
  3. package/lib/agents.mjs +304 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +213 -0
  6. package/lib/check-report.test.mjs +46 -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 +22 -9
  11. package/lib/wait-output.mjs +43 -17
  12. package/lib/wait-output.test.mjs +67 -0
  13. package/package.json +1 -1
  14. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +1 -0
  15. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  16. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  17. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +2 -0
  18. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  19. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  20. package/web/build/client/_app/immutable/chunks/{CdHZDAAo.js → D8sAxZVo.js} +38 -30
  21. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  22. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  23. package/web/build/client/_app/immutable/entry/{app.BRzz8J51.js → app.Dl9OTE-n.js} +2 -2
  24. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  25. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  26. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +1 -0
  27. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +1 -0
  28. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  29. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +1 -0
  30. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  31. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  32. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +66 -0
  33. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  34. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  35. package/web/build/client/_app/immutable/nodes/{3.ByTmpJQZ.js → 3.C-USexyX.js} +2 -2
  36. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  37. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  38. package/web/build/client/_app/version.json +1 -1
  39. package/web/build/client/_app/version.json.br +0 -0
  40. package/web/build/client/_app/version.json.gz +0 -0
  41. package/web/build/handler.js +3 -3
  42. package/web/build/index.js +3 -3
  43. package/web/build/server/chunks/1-0X6Jp9nH.js +7 -0
  44. package/web/build/server/chunks/1-0X6Jp9nH.js.map +1 -0
  45. package/web/build/server/chunks/2-HlcVALg6.js +7 -0
  46. package/web/build/server/chunks/2-HlcVALg6.js.map +1 -0
  47. package/web/build/server/chunks/{3-DZ5PBhOc.js → 3-BN1eS7L2.js} +2 -2
  48. package/web/build/server/chunks/{3-DZ5PBhOc.js.map → 3-BN1eS7L2.js.map} +1 -1
  49. package/web/build/server/chunks/{handler-DD4vP3bU.js → handler-cq3QoEzW.js} +3 -3
  50. package/web/build/server/chunks/{handler-DD4vP3bU.js.map → handler-cq3QoEzW.js.map} +1 -1
  51. package/web/build/server/chunks/{index.js-IhFPHwmp.js → index.js-D4qkHe3r.js} +2 -2
  52. package/web/build/server/chunks/{index.js-IhFPHwmp.js.map → index.js-D4qkHe3r.js.map} +1 -1
  53. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js → manifest.js-DQHMKyJh.js} +6 -6
  54. package/web/build/server/chunks/{manifest.js-Dk3RuAA5.js.map → manifest.js-DQHMKyJh.js.map} +1 -1
  55. package/web/build/server/chunks/{remote-1g4ayag-BRL-uaKE.js → remote-1g4ayag-B8KZPTkn.js} +154 -149
  56. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +1 -0
  57. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  58. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  59. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  60. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js +0 -1
  61. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.br +0 -2
  62. package/web/build/client/_app/immutable/chunks/CO0mUwMJ.js.gz +0 -0
  63. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.br +0 -0
  64. package/web/build/client/_app/immutable/chunks/CdHZDAAo.js.gz +0 -0
  65. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js +0 -2
  66. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.br +0 -0
  67. package/web/build/client/_app/immutable/chunks/DzqTpz7_.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.br +0 -0
  69. package/web/build/client/_app/immutable/entry/app.BRzz8J51.js.gz +0 -0
  70. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js +0 -1
  71. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.br +0 -0
  72. package/web/build/client/_app/immutable/entry/start.BSioRkqK.js.gz +0 -0
  73. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js +0 -1
  74. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.br +0 -1
  75. package/web/build/client/_app/immutable/nodes/1.ws8Khan5.js.gz +0 -0
  76. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js +0 -66
  77. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.br +0 -0
  78. package/web/build/client/_app/immutable/nodes/2.CsD5xqRB.js.gz +0 -0
  79. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.br +0 -0
  80. package/web/build/client/_app/immutable/nodes/3.ByTmpJQZ.js.gz +0 -0
  81. package/web/build/server/chunks/1-qrn24yN5.js +0 -7
  82. package/web/build/server/chunks/1-qrn24yN5.js.map +0 -1
  83. package/web/build/server/chunks/2-C7_Loqyn.js +0 -7
  84. package/web/build/server/chunks/2-C7_Loqyn.js.map +0 -1
  85. package/web/build/server/chunks/remote-1g4ayag-BRL-uaKE.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
+ });