@jpkme/diff-review 0.1.10 → 0.1.13
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.
- package/README.md +38 -15
- package/bin/diff-review.mjs +151 -34
- package/lib/acp-policy.mjs +223 -0
- package/lib/acp-policy.test.mjs +136 -0
- package/lib/acp-shell.mjs +409 -0
- package/lib/acp-shell.test.mjs +158 -0
- package/lib/acp.mjs +368 -0
- package/lib/acp.test.mjs +58 -0
- package/lib/agent-login.mjs +105 -0
- package/lib/agents.mjs +87 -14
- package/lib/agents.test.mjs +4 -6
- package/lib/check-report.mjs +30 -11
- package/lib/check-report.test.mjs +1 -0
- package/lib/extract-cache.test.mjs +162 -0
- package/lib/extract.mjs +197 -1
- package/lib/model-label.mjs +263 -0
- package/lib/model-label.test.mjs +77 -0
- package/lib/models-cache.mjs +75 -0
- package/lib/models-cache.test.mjs +50 -0
- package/lib/output-name.mjs +8 -0
- package/lib/prompt.mjs +168 -22
- package/lib/review-dir.mjs +108 -0
- package/lib/review-meta.mjs +5 -3
- package/lib/review-meta.test.mjs +11 -0
- package/lib/review-prep.mjs +63 -0
- package/lib/validate.mjs +2 -2
- package/lib/wait-output.mjs +46 -23
- package/lib/wait-output.test.mjs +24 -1
- package/package.json +3 -1
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
- package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
- package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js +2 -0
- package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.gz +0 -0
- package/web/build/client/_app/version.json +1 -1
- package/web/build/client/_app/version.json.br +0 -0
- package/web/build/client/_app/version.json.gz +0 -0
- package/web/build/handler.js +3 -3
- package/web/build/index.js +3 -3
- package/web/build/server/chunks/0-DUStDOsr.js +11 -0
- package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
- package/web/build/server/chunks/1-C2qlInYk.js +7 -0
- package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
- package/web/build/server/chunks/2-DyE177MK.js +7 -0
- package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
- package/web/build/server/chunks/3-SO3atuWO.js +7 -0
- package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
- package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
- package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
- package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
- package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
- package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
- package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
- package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
- package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
- package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
- package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
- package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
- package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
- package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
- package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
- package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
- package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
- package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
- package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js.map +1 -0
- package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
- package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
- package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
- package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
- package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
- package/web/build/server/chunks/0-SseDy__O.js +0 -11
- package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
- package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
- package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
- package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
- package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
- package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
- package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
- package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
- package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
- package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
- package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
- package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# diff-review
|
|
2
2
|
|
|
3
|
-
App local para revisar un branch
|
|
4
|
-
|
|
5
|
-
los diffs con `git` y te deja triager hallazgos.
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
App local para revisar un branch con tu agente (Claude, Cursor, Copilot, etc.):
|
|
4
|
+
lanzás la review desde la UI o la CLI, el agente escribe el JSON, la UI completa
|
|
5
|
+
los diffs con `git` y te deja triager hallazgos. Por ACP ves el proceso en vivo y
|
|
6
|
+
después podés preguntarle o pedirle cambios al reporte desde la web. También podés
|
|
7
|
+
copiar el prompt y correrlo en otro agente a mano.
|
|
8
8
|
|
|
9
9
|
## Instalación
|
|
10
10
|
|
|
@@ -27,8 +27,8 @@ npx @jpkme/diff-review
|
|
|
27
27
|
Desde un **repo git**, el default es terminal-first:
|
|
28
28
|
|
|
29
29
|
1. Elegís **branch** y **base** con prompts en la terminal (`@clack/prompts`; tipeá para filtrar).
|
|
30
|
-
2.
|
|
31
|
-
3.
|
|
30
|
+
2. Si hay agentes instalados, el default es lanzar uno (ACP en la web cuando aplica); también podés copiar el prompt al portapapeles.
|
|
31
|
+
3. El agente escribe `diff-rev_{branchTip}_{baseTip}.json`; la CLI valida y abre el viewer.
|
|
32
32
|
|
|
33
33
|
Si pasás `--branch` y `--base` juntos, saltea los selects. Si quedan iguales (diff vacío), abre la UI.
|
|
34
34
|
|
|
@@ -38,8 +38,8 @@ caso el archivo viejo no cuenta hasta que el agente lo reescriba. Sin terminal i
|
|
|
38
38
|
|
|
39
39
|
### Lanzar el agente (`--agent`)
|
|
40
40
|
|
|
41
|
-
Si hay agentes instalados, después de branch y base un solo select lista
|
|
42
|
-
|
|
41
|
+
Si hay agentes instalados, después de branch y base un solo select lista cada agente detectado (default el primero)
|
|
42
|
+
y, al final, copiar el prompt al portapapeles. Con `--agent` saltea esa lista y va directo a elegir agente y modelo:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
diff-review --agent
|
|
@@ -63,13 +63,38 @@ instalados, sigue con el portapapeles. Sin terminal interactiva usa el primer ag
|
|
|
63
63
|
|
|
64
64
|
El agente tiene que estar logueado (p. ej. `claude /login` una vez).
|
|
65
65
|
|
|
66
|
+
#### Por ACP, con la web (Claude Code, Cursor CLI, GitHub Copilot CLI)
|
|
67
|
+
|
|
68
|
+
Si el agente elegido habla ACP, la CLI abre la UI enseguida y el agente corre dentro del server local. Claude Code
|
|
69
|
+
va por el adaptador `@agentclientprotocol/claude-agent-acp` (se baja con `npx` la primera vez); Cursor
|
|
70
|
+
(`cursor-agent acp`) y Copilot (`copilot --acp`) lo traen de fábrica. Codex y Gemini siguen por la terminal.
|
|
71
|
+
|
|
72
|
+
- El select de modelo muestra los que ofrece cada agente (se consultan por ACP la primera vez).
|
|
73
|
+
- La web muestra lo que va haciendo (comandos, archivos que lee, plan). La terminal solo avisa fallos duros o JSON válido.
|
|
74
|
+
- Al terminar cada turno, el server valida el JSON; si falta algo se lo pide al agente hasta dos veces y, cuando valida,
|
|
75
|
+
la web abre el reporte.
|
|
76
|
+
- Cada pedido de permiso pasa por una política fija: leer dentro del repo, escribir únicamente el JSON del review
|
|
77
|
+
y comandos de solo lectura: `git` (diff, log, show, blame…), `cat`, `head`, `tail`, `grep`, `rg`, `find`, `ls`,
|
|
78
|
+
`wc`, `sed -n 'N,Mp'` y sus equivalentes de PowerShell, encadenados con `|`, `&&`, `||` o `;`, con los paths
|
|
79
|
+
dentro del repo. Se rechazan redirecciones a archivos (salvo `/dev/null`), `$(…)`, red, instalaciones y todo lo
|
|
80
|
+
demás; el agente recibe las reglas en el prompt para no intentarlo.
|
|
81
|
+
- La allowlist propia de cada agente no se usa en estas sesiones (aprobaría por nombre de comando, p. ej.
|
|
82
|
+
`echo x > archivo`): Claude corre sin cargar `~/.claude/settings.json` ni el `.claude/` del repo, y Cursor con
|
|
83
|
+
una copia de su configuración sin `permissions.allow` (en el temp del sistema; el login se conserva).
|
|
84
|
+
- La sesión queda viva: en el reporte, el botón **Agente** abre un chat para preguntarle sobre la review
|
|
85
|
+
(no puede escribir nada) o pedirle cambios al JSON (el reporte se actualiza y el triage de los hallazgos que
|
|
86
|
+
siguen se conserva). Si el server se reinició, la sesión se retoma con `session/load`.
|
|
87
|
+
|
|
88
|
+
También se puede lanzar desde la UI, en "2. Generar review" → **Lanzar agente**. Con `--headless` corre en la
|
|
89
|
+
terminal como el resto de los agentes.
|
|
90
|
+
|
|
66
91
|
Para abrir la UI primero (sin esperar el JSON):
|
|
67
92
|
|
|
68
93
|
```bash
|
|
69
94
|
diff-review --ui
|
|
70
95
|
```
|
|
71
96
|
|
|
72
|
-
Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--no-open`.
|
|
97
|
+
Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--headless`, `--no-open`.
|
|
73
98
|
|
|
74
99
|
Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
|
|
75
100
|
|
|
@@ -85,11 +110,9 @@ npm start # mismo que el bin global
|
|
|
85
110
|
## Flujo (UI)
|
|
86
111
|
|
|
87
112
|
1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` / `diff-review --ui` desde el repo.
|
|
88
|
-
2.
|
|
89
|
-
3.
|
|
90
|
-
4.
|
|
91
|
-
|
|
92
|
-
"Ver ejemplo" carga un reporte de muestra sin tocar un repo.
|
|
113
|
+
2. Si hay agentes ACP, el default es **Lanzar agente**; si no, usá **Copiar prompt** y pegalo en tu agente.
|
|
114
|
+
3. El agente escribe `.diff-review/diff-rev_{branchTip}_{baseTip}.json` (en sesiones ACP el server valida solo; si corriste el prompt a mano, `npx --yes @jpkme/diff-review validate <archivo>`).
|
|
115
|
+
4. Con agente integrado o CLI el reporte se abre solo; si no, arrastrá el JSON a la dropzone. La UI hidrata los diffs con git (reusa el companion `.diff` si existe).
|
|
93
116
|
|
|
94
117
|
## Qué hace git (en tu máquina)
|
|
95
118
|
|
package/bin/diff-review.mjs
CHANGED
|
@@ -7,28 +7,40 @@
|
|
|
7
7
|
* diff-review --port 5191
|
|
8
8
|
* diff-review --branch x --base y → saltea selects de Clack
|
|
9
9
|
* diff-review --agent → elegir agente + modelo y lanzarlo con el prompt (sin portapapeles)
|
|
10
|
+
* diff-review --headless → con agente: correrlo en la terminal en vez de por ACP con la web
|
|
10
11
|
* diff-review --no-open
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
14
16
|
import { existsSync } from 'node:fs';
|
|
15
17
|
import { createServer } from 'node:net';
|
|
16
18
|
import { dirname, join } from 'node:path';
|
|
17
19
|
import { fileURLToPath } from 'node:url';
|
|
18
20
|
import * as p from '@clack/prompts';
|
|
19
|
-
import { AGENTS, detectAgents, modelChoices,
|
|
21
|
+
import { AGENTS, detectAgents, modelChoices, resolvedRunModel, runAgent } from '../lib/agents.mjs';
|
|
22
|
+
import { listAcpModelsCached, supportsAcp } from '../lib/acp.mjs';
|
|
23
|
+
import { buildAcpModelSelectOptions, sortModelSelectOptions } from '../lib/model-label.mjs';
|
|
20
24
|
import { autocompleteEs } from '../lib/autocomplete-es.mjs';
|
|
21
25
|
import { copyTextToClipboard } from '../lib/clipboard.mjs';
|
|
22
26
|
import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
|
|
23
27
|
import { makeOutputFilename } from '../lib/output-name.mjs';
|
|
24
|
-
import { buildPrompt } from '../lib/prompt.mjs';
|
|
28
|
+
import { buildPrompt, outputRelPath } from '../lib/prompt.mjs';
|
|
29
|
+
import { prepareReviewPromptOpts } from '../lib/review-prep.mjs';
|
|
30
|
+
import { ensureReviewDir, writeDiffCache } from '../lib/review-dir.mjs';
|
|
25
31
|
import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mjs';
|
|
26
32
|
import { stampReviewProvenance } from '../lib/review-meta.mjs';
|
|
27
33
|
import { runValidateCli } from '../lib/check-report.mjs';
|
|
28
34
|
|
|
29
35
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
36
|
const packageRoot = join(here, '..');
|
|
31
|
-
|
|
37
|
+
// `DIFF_REVIEW_OUT=build-next` cuando `web/build` está bloqueado por un server en marcha (EBUSY en Windows).
|
|
38
|
+
const buildDir = (() => {
|
|
39
|
+
const primary = join(packageRoot, 'web', 'build');
|
|
40
|
+
const overlay = join(packageRoot, 'web', 'build-next');
|
|
41
|
+
if (existsSync(join(overlay, 'index.js'))) return overlay;
|
|
42
|
+
return primary;
|
|
43
|
+
})();
|
|
32
44
|
const entry = join(buildDir, 'index.js');
|
|
33
45
|
|
|
34
46
|
const DEFAULT_HOST = '127.0.0.1';
|
|
@@ -63,6 +75,9 @@ un solo select lista el portapapeles y cada agente detectado.
|
|
|
63
75
|
Si pasás --branch y --base juntos, saltea los selects.
|
|
64
76
|
Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
|
|
65
77
|
y diff-review lo lanza con el prompt.
|
|
78
|
+
Si el agente habla ACP (Claude Code), corre desde la UI web: ves el
|
|
79
|
+
proceso en vivo y después podés preguntarle o pedirle cambios al reporte.
|
|
80
|
+
Con --headless corre en la terminal como antes.
|
|
66
81
|
Con --ui: abre la UI de inmediato (web-first).
|
|
67
82
|
|
|
68
83
|
Options:
|
|
@@ -72,6 +87,7 @@ Options:
|
|
|
72
87
|
--base <b> Base del merge-base (salta el select de base)
|
|
73
88
|
--branch <b> Branch a revisar (salta el select de branch)
|
|
74
89
|
--agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
|
|
90
|
+
--headless Con agente: correrlo en la terminal (sin ACP ni chat en la web)
|
|
75
91
|
--no-open No abrir el navegador
|
|
76
92
|
-h, --help Esta ayuda
|
|
77
93
|
|
|
@@ -87,6 +103,7 @@ function parseArgs(argv) {
|
|
|
87
103
|
help: false,
|
|
88
104
|
ui: false,
|
|
89
105
|
agent: false,
|
|
106
|
+
headless: false,
|
|
90
107
|
base: '',
|
|
91
108
|
branch: ''
|
|
92
109
|
};
|
|
@@ -96,6 +113,7 @@ function parseArgs(argv) {
|
|
|
96
113
|
else if (a === '--no-open') opts.open = false;
|
|
97
114
|
else if (a === '--ui') opts.ui = true;
|
|
98
115
|
else if (a === '--agent') opts.agent = true;
|
|
116
|
+
else if (a === '--headless') opts.headless = true;
|
|
99
117
|
else if (a === '--host') opts.host = argv[++i] || opts.host;
|
|
100
118
|
else if (a === '--base') opts.base = argv[++i] || opts.base;
|
|
101
119
|
else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
|
|
@@ -308,28 +326,55 @@ function copyPromptOrWarn(prompt) {
|
|
|
308
326
|
}
|
|
309
327
|
|
|
310
328
|
/**
|
|
311
|
-
*
|
|
329
|
+
* Opciones de modelo: las que ofrece el agente por ACP (si va a correr así) o, si no, la lista fija.
|
|
312
330
|
* @param {import('../lib/agents.mjs').DetectedAgent} agent
|
|
331
|
+
* @param {{ acpCwd?: string }} opts
|
|
313
332
|
*/
|
|
314
|
-
async function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
333
|
+
async function modelOptions(agent, { acpCwd }) {
|
|
334
|
+
const fixed = modelChoices(agent.def);
|
|
335
|
+
const sortedFixed = {
|
|
336
|
+
...fixed,
|
|
337
|
+
options: sortModelSelectOptions(fixed.options)
|
|
338
|
+
};
|
|
339
|
+
if (!acpCwd) return sortedFixed;
|
|
340
|
+
const spin = p.spinner();
|
|
341
|
+
spin.start(`Consultando los modelos de ${agent.def.label}`);
|
|
342
|
+
try {
|
|
343
|
+
const { models, currentModel, fromCache } = await listAcpModelsCached(agent, { cwd: acpCwd });
|
|
344
|
+
spin.stop(
|
|
345
|
+
fromCache
|
|
346
|
+
? `Modelos de ${agent.def.label} (caché)`
|
|
347
|
+
: `Modelos de ${agent.def.label}`
|
|
348
|
+
);
|
|
349
|
+
if (!models.length) return sortedFixed;
|
|
350
|
+
// Misma fusión/labels/orden que la UI.
|
|
351
|
+
return buildAcpModelSelectOptions({ models, currentModel }, fixed);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
spin.stop(`No pude consultar los modelos (${err instanceof Error ? err.message.split('\n')[0] : err}).`);
|
|
354
|
+
return sortedFixed;
|
|
322
355
|
}
|
|
323
|
-
|
|
356
|
+
}
|
|
324
357
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Modelo del agente: autocomplete con búsqueda; sin TTY, el default (string vacío).
|
|
360
|
+
* @param {import('../lib/agents.mjs').DetectedAgent} agent
|
|
361
|
+
* @param {{ acpCwd?: string }} [opts] con `acpCwd`, la lista sale del agente por ACP
|
|
362
|
+
*/
|
|
363
|
+
async function pickModel(agent, opts = {}) {
|
|
364
|
+
if (!isInteractive()) return '';
|
|
365
|
+
|
|
366
|
+
const { options, initialValue } = await modelOptions(agent, opts);
|
|
367
|
+
return /** @type {string} */ (
|
|
368
|
+
ensureValue(
|
|
369
|
+
await autocompleteEs({
|
|
370
|
+
message: 'Modelo',
|
|
371
|
+
options,
|
|
372
|
+
initialValue,
|
|
373
|
+
placeholder: 'opus, sonnet, gpt…'
|
|
374
|
+
}),
|
|
375
|
+
'modelo'
|
|
376
|
+
)
|
|
331
377
|
);
|
|
332
|
-
return typed.trim();
|
|
333
378
|
}
|
|
334
379
|
|
|
335
380
|
/**
|
|
@@ -337,8 +382,9 @@ async function pickModel(agent) {
|
|
|
337
382
|
* Si `preset` ya viene elegido, solo pregunta el modelo.
|
|
338
383
|
* @param {import('../lib/agents.mjs').DetectedAgent[]} detected
|
|
339
384
|
* @param {import('../lib/agents.mjs').DetectedAgent} [preset]
|
|
385
|
+
* @param {{ repo: string, headless: boolean }} [run] para consultar los modelos por ACP
|
|
340
386
|
*/
|
|
341
|
-
async function pickAgentAndModel(detected, preset) {
|
|
387
|
+
async function pickAgentAndModel(detected, preset, run) {
|
|
342
388
|
if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
|
|
343
389
|
|
|
344
390
|
let agent = preset;
|
|
@@ -354,11 +400,12 @@ async function pickAgentAndModel(detected, preset) {
|
|
|
354
400
|
agent = detected.find((a) => a.def.id === id) ?? detected[0];
|
|
355
401
|
}
|
|
356
402
|
|
|
357
|
-
|
|
403
|
+
const acpCwd = run && !run.headless && supportsAcp(agent) ? run.repo : undefined;
|
|
404
|
+
return { agent, model: await pickModel(agent, { acpCwd }) };
|
|
358
405
|
}
|
|
359
406
|
|
|
360
407
|
/**
|
|
361
|
-
*
|
|
408
|
+
* Agentes detectados primero (default el primero); portapapeles como opción secundaria.
|
|
362
409
|
* @param {import('../lib/agents.mjs').DetectedAgent[]} detected
|
|
363
410
|
* @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
|
|
364
411
|
*/
|
|
@@ -367,10 +414,10 @@ async function pickDelivery(detected) {
|
|
|
367
414
|
await p.select({
|
|
368
415
|
message: '¿Cómo generás el review?',
|
|
369
416
|
options: [
|
|
370
|
-
{ value:
|
|
371
|
-
|
|
417
|
+
...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path })),
|
|
418
|
+
{ value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' }
|
|
372
419
|
],
|
|
373
|
-
initialValue:
|
|
420
|
+
initialValue: detected[0].def.id
|
|
374
421
|
}),
|
|
375
422
|
'modo'
|
|
376
423
|
);
|
|
@@ -407,7 +454,7 @@ async function reuseExistingReview(existing, outputFilename) {
|
|
|
407
454
|
*/
|
|
408
455
|
async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
|
|
409
456
|
const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
|
|
410
|
-
const msg = `Lanzando ${what}. Espera ${outputFilename}
|
|
457
|
+
const msg = `Lanzando ${what}. Espera ${outputRelPath(outputFilename)}; Ctrl+C cancela.`;
|
|
411
458
|
if (useClack) p.outro(msg);
|
|
412
459
|
else console.log(msg);
|
|
413
460
|
|
|
@@ -447,11 +494,33 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
|
|
|
447
494
|
return /** @type {{ path: string }} */ (result);
|
|
448
495
|
}
|
|
449
496
|
|
|
497
|
+
/**
|
|
498
|
+
* El agente corre dentro del server de la UI (ACP): la web muestra el proceso y abre el reporte; la terminal ve un
|
|
499
|
+
* resumen. La sesión queda viva para preguntas y cambios desde el reporte.
|
|
500
|
+
* @param {import('../lib/agents.mjs').DetectedAgent} agent
|
|
501
|
+
* @param {{ repo: string, branch: string, base: string, model: string, outputFilename: string, useClack: boolean }} run
|
|
502
|
+
* @param {{ host: string, port: number, open: boolean }} opts
|
|
503
|
+
*/
|
|
504
|
+
async function runAgentInUi(agent, { repo, branch, base, model, outputFilename, useClack }, opts) {
|
|
505
|
+
const id = randomUUID();
|
|
506
|
+
const msg = `Lanzando ${agent.def.label} (${model || 'modelo por defecto'}). El progreso se ve en la web; Ctrl+C corta.`;
|
|
507
|
+
if (useClack) p.outro(msg);
|
|
508
|
+
else console.log(msg);
|
|
509
|
+
await startServer({
|
|
510
|
+
host: opts.host,
|
|
511
|
+
port: opts.port,
|
|
512
|
+
open: opts.open,
|
|
513
|
+
launchCwd: repo,
|
|
514
|
+
openPath: `/?agent=${encodeURIComponent(id)}`,
|
|
515
|
+
agentRun: { id, repo, branch, base, outputFilename, agentId: agent.def.id, ...(model ? { model } : {}) }
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
450
519
|
function explainWaitingForAgent(outputFilename, { copied, useClack }) {
|
|
451
520
|
const copyMsg = copied
|
|
452
521
|
? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
|
|
453
522
|
: 'Generá el JSON con tu agente de IA usando el prompt de esta sesión (no se pudo copiar solo al portapapeles).';
|
|
454
|
-
const waitMsg = `Aguardando el resultado del agente (${outputFilename}
|
|
523
|
+
const waitMsg = `Aguardando el resultado del agente (${outputRelPath(outputFilename)}). La UI web se abre cuando ese archivo esté listo.`;
|
|
455
524
|
|
|
456
525
|
if (useClack) {
|
|
457
526
|
p.log.success(copyMsg);
|
|
@@ -465,7 +534,8 @@ function explainWaitingForAgent(outputFilename, { copied, useClack }) {
|
|
|
465
534
|
}
|
|
466
535
|
|
|
467
536
|
/**
|
|
468
|
-
* @
|
|
537
|
+
* @typedef {{ id: string, repo: string, branch: string, base: string, outputFilename: string, agentId: string, model?: string }} AgentRun
|
|
538
|
+
* @param {{ host: string, port: number, open: boolean, launchCwd: string, importFile?: string, openPath?: string, agentRun?: AgentRun }} cfg
|
|
469
539
|
*/
|
|
470
540
|
async function startServer(cfg) {
|
|
471
541
|
if (!existsSync(entry)) {
|
|
@@ -495,8 +565,11 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
|
|
|
495
565
|
HOST: cfg.host,
|
|
496
566
|
PORT: String(port),
|
|
497
567
|
ORIGIN: origin,
|
|
568
|
+
// Reviews con seed/diff o import JSON suelen pasar los 512K por defecto del adapter-node.
|
|
569
|
+
BODY_SIZE_LIMIT: process.env.BODY_SIZE_LIMIT || '16M',
|
|
498
570
|
DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
|
|
499
|
-
...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
|
|
571
|
+
...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {}),
|
|
572
|
+
...(cfg.agentRun ? { DIFF_REVIEW_AGENT_RUN: JSON.stringify(cfg.agentRun), DIFF_REVIEW_MIRROR: '1' } : {})
|
|
500
573
|
},
|
|
501
574
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
502
575
|
windowsHide: false
|
|
@@ -514,6 +587,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
|
|
|
514
587
|
ready = true;
|
|
515
588
|
console.log(`diff-review listo en ${terminalLink(origin)} — Ctrl+C para salir.`);
|
|
516
589
|
if (cfg.open) openBrowser(openUrl);
|
|
590
|
+
else if (cfg.agentRun) console.log(`Progreso del agente: ${terminalLink(openUrl)}`);
|
|
517
591
|
}
|
|
518
592
|
continue;
|
|
519
593
|
}
|
|
@@ -548,6 +622,22 @@ async function runUiMode(opts, launch, launchCwd) {
|
|
|
548
622
|
if (launch) {
|
|
549
623
|
console.log(`Repo: ${launch.repo}`);
|
|
550
624
|
console.log(`Branch: ${branch} (base: ${base})`);
|
|
625
|
+
let promptOpts = { outputFilename };
|
|
626
|
+
try {
|
|
627
|
+
promptOpts = prepareReviewPromptOpts({
|
|
628
|
+
repo: launch.repo,
|
|
629
|
+
branch,
|
|
630
|
+
base,
|
|
631
|
+
outputFilename
|
|
632
|
+
});
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.warn(
|
|
635
|
+
'No pude precomputar el diff:',
|
|
636
|
+
err instanceof Error ? err.message : err
|
|
637
|
+
);
|
|
638
|
+
ensureReviewDir(launch.repo);
|
|
639
|
+
writeDiffCache(launch.repo, outputFilename, { branch, base });
|
|
640
|
+
}
|
|
551
641
|
const prompt = buildPrompt(
|
|
552
642
|
{
|
|
553
643
|
source: 'local',
|
|
@@ -556,7 +646,7 @@ async function runUiMode(opts, launch, launchCwd) {
|
|
|
556
646
|
base,
|
|
557
647
|
remoteUrl: launch.remoteUrl
|
|
558
648
|
},
|
|
559
|
-
|
|
649
|
+
promptOpts
|
|
560
650
|
);
|
|
561
651
|
if (copyTextToClipboard(prompt)) {
|
|
562
652
|
console.log('Prompt copiado al portapapeles.');
|
|
@@ -623,12 +713,28 @@ async function main() {
|
|
|
623
713
|
}
|
|
624
714
|
|
|
625
715
|
const outputFilename = buildOutputFilename(launch.repo, branch, base);
|
|
716
|
+
// El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
|
|
717
|
+
let promptOpts = { outputFilename };
|
|
718
|
+
try {
|
|
719
|
+
promptOpts = prepareReviewPromptOpts({
|
|
720
|
+
repo: launch.repo,
|
|
721
|
+
branch,
|
|
722
|
+
base,
|
|
723
|
+
outputFilename
|
|
724
|
+
});
|
|
725
|
+
} catch (err) {
|
|
726
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
727
|
+
if (prompted) p.log.warn(`No pude precomputar el diff: ${msg}`);
|
|
728
|
+
else console.warn('No pude precomputar el diff:', msg);
|
|
729
|
+
ensureReviewDir(launch.repo);
|
|
730
|
+
writeDiffCache(launch.repo, outputFilename, { branch, base });
|
|
731
|
+
}
|
|
626
732
|
|
|
627
733
|
if (!prompted) {
|
|
628
734
|
console.log(`Repo: ${launch.repo}`);
|
|
629
735
|
console.log(`Branch: ${branch} (base: ${base})`);
|
|
630
736
|
} else {
|
|
631
|
-
p.note(`${branch} → ${base}\n${outputFilename}`, 'Review');
|
|
737
|
+
p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
|
|
632
738
|
}
|
|
633
739
|
|
|
634
740
|
const prompt = buildPrompt(
|
|
@@ -639,7 +745,7 @@ async function main() {
|
|
|
639
745
|
base,
|
|
640
746
|
remoteUrl: launch.remoteUrl
|
|
641
747
|
},
|
|
642
|
-
|
|
748
|
+
promptOpts
|
|
643
749
|
);
|
|
644
750
|
|
|
645
751
|
// Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
|
|
@@ -665,7 +771,18 @@ async function main() {
|
|
|
665
771
|
}
|
|
666
772
|
const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
|
|
667
773
|
if (useAgent) {
|
|
668
|
-
const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined
|
|
774
|
+
const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined, {
|
|
775
|
+
repo: launch.repo,
|
|
776
|
+
headless: opts.headless
|
|
777
|
+
});
|
|
778
|
+
if (supportsAcp(agent) && !opts.headless) {
|
|
779
|
+
await runAgentInUi(
|
|
780
|
+
agent,
|
|
781
|
+
{ repo: launch.repo, branch, base, model, outputFilename, useClack },
|
|
782
|
+
opts
|
|
783
|
+
);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
669
786
|
result = await runAgentAndWait(agent, {
|
|
670
787
|
repo: launch.repo,
|
|
671
788
|
prompt,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qué puede hacer el agente cuando corre por ACP. Cada `session/request_permission` pasa por acá: no hay nadie
|
|
3
|
+
* mirando la terminal para aprobar, así que la política es una allowlist (leer, comandos de solo lectura y escribir
|
|
4
|
+
* el JSON de salida en `.diff-review/`) y todo lo demás se rechaza.
|
|
5
|
+
*
|
|
6
|
+
* Ojo: el agente solo pide permiso para lo que *su* configuración no aprueba sola (p. ej. Claude Code aplica la
|
|
7
|
+
* allowlist de `~/.claude/settings.json` antes de preguntar). Esto acota lo que pide, no lo que el usuario ya
|
|
8
|
+
* habilitó en su agente.
|
|
9
|
+
*/
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
|
|
12
|
+
import { checkShell } from "./acp-shell.mjs";
|
|
13
|
+
import { outputRelPath } from "./output-name.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {"review" | "ask" | "edit"} PolicyMode
|
|
17
|
+
* review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
|
|
18
|
+
* @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* kind?: string | null,
|
|
21
|
+
* title?: string | null,
|
|
22
|
+
* rawInput?: unknown,
|
|
23
|
+
* locations?: { path: string }[] | null,
|
|
24
|
+
* content?: { type: string, path?: string }[] | null,
|
|
25
|
+
* }} ToolCallLike
|
|
26
|
+
* @typedef {{ allow: boolean, reason: string }} Decision
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @param {string} a @param {string} b */
|
|
30
|
+
function samePath(a, b, platform = process.platform) {
|
|
31
|
+
const na = normalize(a);
|
|
32
|
+
const nb = normalize(b);
|
|
33
|
+
return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link dentro del repo
|
|
38
|
+
* puede apuntar fuera y pasar `isInside` con solo `resolve`.
|
|
39
|
+
* @param {string} path
|
|
40
|
+
*/
|
|
41
|
+
function canonicalPath(path) {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(path);
|
|
44
|
+
} catch {
|
|
45
|
+
const parts = [];
|
|
46
|
+
let cur = normalize(path);
|
|
47
|
+
for (;;) {
|
|
48
|
+
const parent = dirname(cur);
|
|
49
|
+
if (!parent || parent === cur) return normalize(path);
|
|
50
|
+
parts.unshift(basename(cur));
|
|
51
|
+
try {
|
|
52
|
+
return resolve(realpathSync(parent), ...parts);
|
|
53
|
+
} catch {
|
|
54
|
+
cur = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {string} child @param {string} root */
|
|
61
|
+
function isInside(child, root, platform = process.platform) {
|
|
62
|
+
const c = normalize(canonicalPath(child));
|
|
63
|
+
const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
|
|
64
|
+
const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
|
|
65
|
+
return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Paths que toca la tool call (`locations`, diffs de `content` y los campos típicos de `rawInput`). */
|
|
69
|
+
export function toolCallPaths(toolCall, repo) {
|
|
70
|
+
const raw = toolCall.rawInput && typeof toolCall.rawInput === "object" ? /** @type {Record<string, unknown>} */ (toolCall.rawInput) : {};
|
|
71
|
+
const candidates = [
|
|
72
|
+
...(toolCall.locations ?? []).map((l) => l?.path),
|
|
73
|
+
...(toolCall.content ?? []).map((c) => (c?.type === "diff" ? c.path : undefined)),
|
|
74
|
+
raw.file_path,
|
|
75
|
+
raw.path,
|
|
76
|
+
raw.filePath,
|
|
77
|
+
raw.notebook_path,
|
|
78
|
+
];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const c of candidates) {
|
|
81
|
+
if (typeof c !== "string" || !c.trim()) continue;
|
|
82
|
+
const abs = isAbsolute(c) ? c : resolve(repo, c);
|
|
83
|
+
if (!out.some((p) => samePath(p, abs))) out.push(abs);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {unknown} rawInput */
|
|
89
|
+
function commandOf(rawInput) {
|
|
90
|
+
if (!rawInput || typeof rawInput !== "object") return "";
|
|
91
|
+
const cmd = /** @type {Record<string, unknown>} */ (rawInput).command;
|
|
92
|
+
if (Array.isArray(cmd)) return cmd.map(String).join(" ");
|
|
93
|
+
return typeof cmd === "string" ? cmd : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Comando de shell que la política evalúa (rawInput, backticks o título entero).
|
|
98
|
+
* Cursor a veces envuelve el script con `@…@` en el título.
|
|
99
|
+
* @param {ToolCallLike} toolCall
|
|
100
|
+
*/
|
|
101
|
+
export function toolCallCommand(toolCall) {
|
|
102
|
+
const fromRaw = commandOf(toolCall.rawInput).trim();
|
|
103
|
+
if (fromRaw) return fromRaw;
|
|
104
|
+
const title = String(toolCall.title ?? "").trim();
|
|
105
|
+
const tick = /^`([\s\S]+)`$/.exec(title);
|
|
106
|
+
if (tick) return tick[1].trim();
|
|
107
|
+
const at = /^@\s*([\s\S]*?)\s*@$/.exec(title);
|
|
108
|
+
if (at) return at[1].trim();
|
|
109
|
+
return title;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Etiqueta corta del agente si el título no es el comando mismo.
|
|
114
|
+
* @param {ToolCallLike} toolCall
|
|
115
|
+
* @param {string} [command]
|
|
116
|
+
*/
|
|
117
|
+
export function toolCallLabel(toolCall, command = toolCallCommand(toolCall)) {
|
|
118
|
+
const title = String(toolCall.title ?? "").trim();
|
|
119
|
+
if (!title) return "";
|
|
120
|
+
if (/^`[\s\S]+`$/.test(title) || /^@\s*[\s\S]*?\s*@$/.test(title)) return "";
|
|
121
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim();
|
|
122
|
+
if (command && norm(title) === norm(command)) return "";
|
|
123
|
+
if (command && command.length >= 24 && title.includes(command.slice(0, 24))) return "";
|
|
124
|
+
return title;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Motivo de denegación legible en la UI de permisos.
|
|
129
|
+
* @param {string} reason
|
|
130
|
+
*/
|
|
131
|
+
export function humanizePolicyReason(reason) {
|
|
132
|
+
const r = String(reason || "").trim();
|
|
133
|
+
if (!r) return "La política bloqueó este pedido.";
|
|
134
|
+
let m;
|
|
135
|
+
if ((m = /^(.+?) no está permitido$/.exec(r))) {
|
|
136
|
+
return `La política bloqueó «${m[1]}» (no está en la allowlist de comandos).`;
|
|
137
|
+
}
|
|
138
|
+
if ((m = /^variables de entorno en el comando:\s*(.*)$/s.exec(r))) {
|
|
139
|
+
const token = String(m[1] || "?").trim().slice(0, 40);
|
|
140
|
+
return `La política interpretó parte del comando como variable de entorno (${token}) y lo bloqueó.`;
|
|
141
|
+
}
|
|
142
|
+
if ((m = /^path fuera del repo:\s*(.*)$/s.exec(r))) {
|
|
143
|
+
return `El path queda fuera del repo: ${m[1]}`;
|
|
144
|
+
}
|
|
145
|
+
if ((m = /^lectura fuera del repo:\s*(.*)$/s.exec(r))) {
|
|
146
|
+
return `Lectura fuera del repo: ${m[1]}`;
|
|
147
|
+
}
|
|
148
|
+
if ((m = /^opción global de git no permitida:\s*(.*)$/s.exec(r))) {
|
|
149
|
+
return `Opción de git no permitida: ${m[1]}`;
|
|
150
|
+
}
|
|
151
|
+
if (r.startsWith("git ")) return `Comando git no permitido por la política: ${r}`;
|
|
152
|
+
if (r.startsWith("solo se puede escribir") || r.includes("sin path") || r.startsWith("modo pregunta")) return r;
|
|
153
|
+
return `La política bloqueó el pedido: ${r}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* ¿El comando de shell solo lee? Ver `checkShell` en `acp-shell.mjs`.
|
|
158
|
+
* @param {string} command
|
|
159
|
+
* @param {{ repo?: string }} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export function isAllowedShell(command, opts) {
|
|
162
|
+
return checkShell(command, opts).ok;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {ToolCallLike} toolCall
|
|
167
|
+
* @param {PolicyContext} ctx
|
|
168
|
+
* @returns {Decision}
|
|
169
|
+
*/
|
|
170
|
+
export function decidePermission(toolCall, { repo, outputFilename, mode }) {
|
|
171
|
+
const kind = toolCall.kind ?? "other";
|
|
172
|
+
const paths = toolCallPaths(toolCall, repo);
|
|
173
|
+
|
|
174
|
+
if (kind === "read" || kind === "search") {
|
|
175
|
+
if (!paths.length) {
|
|
176
|
+
return { allow: false, reason: `${kind === "search" ? "búsqueda" : "lectura"} sin path` };
|
|
177
|
+
}
|
|
178
|
+
const outside = paths.find((p) => !isInside(p, repo));
|
|
179
|
+
return outside ? { allow: false, reason: `lectura fuera del repo: ${outside}` } : { allow: true, reason: "lectura" };
|
|
180
|
+
}
|
|
181
|
+
if (kind === "think") return { allow: true, reason: "pensar" };
|
|
182
|
+
|
|
183
|
+
if (kind === "execute") {
|
|
184
|
+
// Cursor no manda `rawInput`: el comando viene en el título (backticks / @…@ / texto).
|
|
185
|
+
const cmd = toolCallCommand(toolCall);
|
|
186
|
+
const check = checkShell(cmd, { repo });
|
|
187
|
+
return check.ok ? { allow: true, reason: "comando de solo lectura" } : { allow: false, reason: check.reason };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (kind === "edit" || kind === "delete") {
|
|
191
|
+
if (mode === "ask") return { allow: false, reason: "modo pregunta: no se edita nada" };
|
|
192
|
+
if (!paths.length) return { allow: false, reason: `${kind === "delete" ? "borrado" : "edición"} sin path` };
|
|
193
|
+
const target = resolve(repo, outputRelPath(outputFilename));
|
|
194
|
+
const outside = paths.find((p) => !samePath(canonicalPath(p), canonicalPath(target)));
|
|
195
|
+
return outside
|
|
196
|
+
? {
|
|
197
|
+
allow: false,
|
|
198
|
+
reason: `solo se puede escribir ${outputRelPath(outputFilename)} (no ${outside})`
|
|
199
|
+
}
|
|
200
|
+
: { allow: true, reason: `escritura de ${outputRelPath(outputFilename)}` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { allow: false, reason: `${kind} no permitido: ${toolCall.title ?? "?"}` };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Respuesta ACP para una decisión del usuario / política.
|
|
208
|
+
* - allow + once → `allow_once` (la política se vuelve a evaluar en el próximo pedido).
|
|
209
|
+
* - allow + always → `allow_always` si el agente la ofrece; si no, cae a `allow_once`.
|
|
210
|
+
* @param {{ optionId: string, kind: string }[]} options
|
|
211
|
+
* @param {boolean} allow
|
|
212
|
+
* @param {{ always?: boolean }} [opts]
|
|
213
|
+
*/
|
|
214
|
+
export function permissionOutcome(options, allow, opts = {}) {
|
|
215
|
+
const always = Boolean(opts.always);
|
|
216
|
+
const kinds = allow
|
|
217
|
+
? always
|
|
218
|
+
? ["allow_always", "allow_once"]
|
|
219
|
+
: ["allow_once"]
|
|
220
|
+
: ["reject_once", "reject_always"];
|
|
221
|
+
const option = kinds.map((kind) => options.find((o) => o.kind === kind)).find(Boolean);
|
|
222
|
+
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
|
|
223
|
+
}
|