diffsplain 0.5.0 → 0.7.0

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 (92) hide show
  1. package/README.md +17 -12
  2. package/dist/assets/{angular-html-CoQle5-T.js → angular-html-CphzFC_v.js} +1 -1
  3. package/dist/assets/{angular-ts-BB_A1Lpy.js → angular-ts-Am0-mbFw.js} +1 -1
  4. package/dist/assets/{apl-B7_4_FNq.js → apl-Cs2rPKDN.js} +1 -1
  5. package/dist/assets/{astro-CXVo4VE4.js → astro-CxKUUn9h.js} +1 -1
  6. package/dist/assets/{blade-BVX41dq7.js → blade-DqeCa6v5.js} +1 -1
  7. package/dist/assets/{c-Dhyi8Nd2.js → c-DdI6lknk.js} +1 -1
  8. package/dist/assets/{cobol-CU5oLeZj.js → cobol-COuAGE2X.js} +1 -1
  9. package/dist/assets/{coffee-D_FU6gC_.js → coffee-BFlYT3Lp.js} +1 -1
  10. package/dist/assets/{cpp-CxoYLSi3.js → cpp-nywLU0Np.js} +1 -1
  11. package/dist/assets/{crystal-BdgqBN3z.js → crystal-BWp4dz1s.js} +1 -1
  12. package/dist/assets/{css-wLk1vVwJ.js → css-CRb9tUpU.js} +1 -1
  13. package/dist/assets/{edge-DMylWoGl.js → edge-rUYSbJy4.js} +1 -1
  14. package/dist/assets/{elixir-Djmgto_Z.js → elixir-DOq9ybIH.js} +1 -1
  15. package/dist/assets/{elm-D7B-eBLA.js → elm-C8e38E5-.js} +1 -1
  16. package/dist/assets/{erb-g1sso1GW.js → erb-BR9Q37Et.js} +1 -1
  17. package/dist/assets/{git-rebase-DEAhxutZ.js → git-rebase-Coo2LUtB.js} +1 -1
  18. package/dist/assets/{glimmer-js-CHvGFGMp.js → glimmer-js-C7hX9jg6.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DvhL705p.js → glimmer-ts-CZLT0F0k.js} +1 -1
  20. package/dist/assets/{glsl-C8mS_t8I.js → glsl-BDZRv6e-.js} +1 -1
  21. package/dist/assets/{graphql-DW2pnvqi.js → graphql-DyzFc3y3.js} +1 -1
  22. package/dist/assets/{hack-DYc1d3he.js → hack-CWnO7WsF.js} +1 -1
  23. package/dist/assets/{haml-_5OmzH31.js → haml-CvDj-ghO.js} +1 -1
  24. package/dist/assets/{handlebars-rN-CaXUf.js → handlebars-BnU_YcVA.js} +1 -1
  25. package/dist/assets/{html-DQnaJUjl.js → html-CjB2TKKa.js} +1 -1
  26. package/dist/assets/{html-derivative-BovOubry.js → html-derivative-CYQqEXS6.js} +1 -1
  27. package/dist/assets/{http-BKTgwOYc.js → http-NNQbnqIx.js} +1 -1
  28. package/dist/assets/{hurl-ChS3_IgD.js → hurl-DHaAA1BS.js} +1 -1
  29. package/dist/assets/{index-BLtRBc2n.css → index-C2p_1iAk.css} +1 -1
  30. package/dist/assets/{index-CEglkSs-.js → index-f9ekN94b.js} +11 -11
  31. package/dist/assets/{java-Dgnyk--z.js → java-D1J5V2CD.js} +1 -1
  32. package/dist/assets/{javascript-BfnrwkRN.js → javascript-C3pX-HsE.js} +1 -1
  33. package/dist/assets/{jinja-CD2AIvOi.js → jinja-BY9CIY8g.js} +1 -1
  34. package/dist/assets/{jison-DmKNw3Pl.js → jison-Cr0H89gS.js} +1 -1
  35. package/dist/assets/{json-CKB5a8aj.js → json-CA3kKNfq.js} +1 -1
  36. package/dist/assets/{jsx-jJXrDRBM.js → jsx-BuMkR-Lj.js} +1 -1
  37. package/dist/assets/{julia-G8vVBW-t.js → julia-C2sLwg3f.js} +1 -1
  38. package/dist/assets/{just-DMXLrLZL.js → just-pXpkLBXv.js} +1 -1
  39. package/dist/assets/{latex-DS3xuxld.js → latex-7v3EPOdF.js} +1 -1
  40. package/dist/assets/{liquid-COQnD_C4.js → liquid-CJZq0Z0t.js} +1 -1
  41. package/dist/assets/{lua-BwMKwosd.js → lua-BimBzvTH.js} +1 -1
  42. package/dist/assets/{marko-cQCK_WIk.js → marko-CCZHVnQN.js} +1 -1
  43. package/dist/assets/{mdc-DQUjY13N.js → mdc-SLECNw2A.js} +1 -1
  44. package/dist/assets/{nginx-C4Kv3DJi.js → nginx-DUwtUge2.js} +1 -1
  45. package/dist/assets/{nim-ChzLzuYD.js → nim-HD8o_Omp.js} +1 -1
  46. package/dist/assets/{perl-z5xUiWAY.js → perl-BGMeIkNk.js} +1 -1
  47. package/dist/assets/{php-BHqm-Xeh.js → php-BL5xuY-v.js} +1 -1
  48. package/dist/assets/{pug-C9C_Llft.js → pug-C8G1U_u8.js} +1 -1
  49. package/dist/assets/{qml-dtpuwXJZ.js → qml-BGVSyw1x.js} +1 -1
  50. package/dist/assets/{r-BicQMs_c.js → r-BDnRcclC.js} +1 -1
  51. package/dist/assets/{razor-D5h00YTZ.js → razor-I8tbEIVJ.js} +1 -1
  52. package/dist/assets/{regexp-CTa8S86v.js → regexp-D0yakB9Q.js} +1 -1
  53. package/dist/assets/{rst-Bg-b-7HK.js → rst-DbTCn07T.js} +1 -1
  54. package/dist/assets/{ruby-BiWYm056.js → ruby-BwgPFIcO.js} +1 -1
  55. package/dist/assets/{sas-bJTdfgUz.js → sas-dN4ePfXU.js} +1 -1
  56. package/dist/assets/{scss-0qkNyHr1.js → scss-T7M1A4iM.js} +1 -1
  57. package/dist/assets/{shellscript-3sDkDE9z.js → shellscript-HFjBHS7_.js} +1 -1
  58. package/dist/assets/{shellsession-D4EBhEmz.js → shellsession-DCAGskrx.js} +1 -1
  59. package/dist/assets/{soy-DG9hUwLL.js → soy-BgT96TF9.js} +1 -1
  60. package/dist/assets/{sql-B0UoKHkx.js → sql-DCIQeRzx.js} +1 -1
  61. package/dist/assets/{stata-CsQbuXQp.js → stata-Dn0tad0_.js} +1 -1
  62. package/dist/assets/{surrealql-D63VLxos.js → surrealql-CCgFEQfW.js} +1 -1
  63. package/dist/assets/{svelte-BApdnAma.js → svelte-ITXimG8u.js} +1 -1
  64. package/dist/assets/{templ-B64pN6Lz.js → templ-DJVZZqPw.js} +1 -1
  65. package/dist/assets/{tex-DR2UVIPF.js → tex-Bn4N-OtH.js} +1 -1
  66. package/dist/assets/{ts-tags-IwhomvQv.js → ts-tags-CxQ4PoC4.js} +1 -1
  67. package/dist/assets/{tsx-DROdxqzS.js → tsx-CxwgcjkC.js} +1 -1
  68. package/dist/assets/{twig-BtbUqpR4.js → twig-CtbyTyMH.js} +1 -1
  69. package/dist/assets/{typescript-CHpSjYZL.js → typescript-d6ru12L8.js} +1 -1
  70. package/dist/assets/{vue-DCvEu_ug.js → vue-RvqAsYDm.js} +1 -1
  71. package/dist/assets/{vue-html-BgL4Rf-L.js → vue-html-BkHDVENe.js} +1 -1
  72. package/dist/assets/{vue-vine-CRNdhPTn.js → vue-vine-DvS3Mlkn.js} +1 -1
  73. package/dist/assets/{xml-CkfPxZuz.js → xml-lzTq5A-V.js} +1 -1
  74. package/dist/assets/{xsl-CteaoFSV.js → xsl-BL0KXNWI.js} +1 -1
  75. package/dist/assets/{yaml-CUr3TDRN.js → yaml-CF4XlBJS.js} +1 -1
  76. package/dist/demo-diff-data.json +6 -1
  77. package/dist/index.html +2 -2
  78. package/package.json +50 -23
  79. package/scripts/access-token.mjs +21 -0
  80. package/scripts/build-diff-data.mjs +260 -39
  81. package/scripts/cache.mjs +301 -0
  82. package/scripts/cli-args.mjs +206 -57
  83. package/scripts/coding-agents.mjs +279 -162
  84. package/scripts/dev.mjs +218 -0
  85. package/scripts/doctor.mjs +265 -46
  86. package/scripts/generate-summaries.mjs +760 -260
  87. package/scripts/mock-agent.mjs +165 -0
  88. package/scripts/present.mjs +394 -85
  89. package/scripts/presenter-runtime.mjs +148 -0
  90. package/scripts/serve-built.mjs +343 -71
  91. package/scripts/summary-path.mjs +24 -7
  92. package/scripts/support-record.mjs +242 -0
@@ -0,0 +1,301 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ linkSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ unlinkSync,
14
+ utimesSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { hostname } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import { defaultCacheRoot } from './summary-path.mjs';
20
+
21
+ export const leaseDurationMs = 5 * 60_000;
22
+
23
+ function files(root) {
24
+ try {
25
+ return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
26
+ const path = join(root, entry.name);
27
+ return entry.isDirectory() ? files(path) : [path];
28
+ });
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function leaseRecord(path) {
35
+ try {
36
+ const value = JSON.parse(readFileSync(path, 'utf8'));
37
+ return typeof value?.token === 'string' ? value : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ function leaseIsActive(path, now = Date.now(), duration = leaseDurationMs) {
44
+ try {
45
+ return now - statSync(path).mtimeMs < duration;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function createLease(path, record, duration) {
52
+ const descriptor = openSync(path, 'wx', 0o600);
53
+ writeFileSync(descriptor, `${JSON.stringify(record)}\n`);
54
+ closeSync(descriptor);
55
+ return { path, token: record.token, duration };
56
+ }
57
+
58
+ function renameLease(path, stalePath) {
59
+ try {
60
+ renameSync(path, stalePath);
61
+ return true;
62
+ } catch (error) {
63
+ if (error?.code !== 'ENOENT') throw error;
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function restoreLease(path, stalePath) {
69
+ try {
70
+ linkSync(stalePath, path);
71
+ } catch (error) {
72
+ if (error?.code !== 'EEXIST') throw error;
73
+ }
74
+ rmSync(stalePath, { force: true });
75
+ }
76
+
77
+ export function removeStaleLease(path, observedToken) {
78
+ const stalePath = `${path}.stale-${randomUUID()}`;
79
+ if (!renameLease(path, stalePath)) return false;
80
+ if (leaseRecord(stalePath)?.token === observedToken) {
81
+ rmSync(stalePath, { force: true });
82
+ return true;
83
+ }
84
+ restoreLease(path, stalePath);
85
+ return false;
86
+ }
87
+
88
+ function rejectNonConflict(error) {
89
+ if (error?.code !== 'EEXIST') throw error;
90
+ }
91
+
92
+ function rejectActiveLease(active, path) {
93
+ if (active) {
94
+ throw new Error(`Notes for this target are already being generated: ${path}`);
95
+ }
96
+ }
97
+
98
+ function handleLeaseConflict(error, path, now, duration) {
99
+ rejectNonConflict(error);
100
+ const observed = leaseRecord(path);
101
+ rejectActiveLease(leaseIsActive(path, now, duration), path);
102
+ removeStaleLease(path, observed?.token);
103
+ }
104
+
105
+ export function acquireLease(path, {
106
+ token = randomUUID(),
107
+ now = Date.now(),
108
+ duration = leaseDurationMs,
109
+ pid = process.pid,
110
+ leaseHostname = hostname(),
111
+ } = {}) {
112
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
113
+ for (;;) {
114
+ try {
115
+ return createLease(
116
+ path,
117
+ { token, startedAt: now, pid, hostname: leaseHostname },
118
+ duration,
119
+ );
120
+ } catch (error) {
121
+ handleLeaseConflict(error, path, now, duration);
122
+ }
123
+ }
124
+ }
125
+
126
+ function assertLease(lease) {
127
+ if (leaseRecord(lease.path)?.token !== lease.token) {
128
+ throw new Error('This process no longer owns the note cache');
129
+ }
130
+ }
131
+
132
+ export function refreshLease(lease, now = Date.now()) {
133
+ assertLease(lease);
134
+ utimesSync(lease.path, new Date(now), new Date(now));
135
+ assertLease(lease);
136
+ }
137
+
138
+ export function releaseLease(lease) {
139
+ assertLease(lease);
140
+ unlinkSync(lease.path);
141
+ }
142
+
143
+ export function writePrivateFile(path, value) {
144
+ writeFileAtomic(path, value, 0o600);
145
+ }
146
+
147
+ function existingFileMode(path) {
148
+ try {
149
+ return statSync(path).mode & 0o777;
150
+ } catch (error) {
151
+ if (error?.code !== 'ENOENT') throw error;
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ function chmodIfSet(path, mode) {
157
+ if (mode !== undefined) chmodSync(path, mode);
158
+ }
159
+
160
+ function writeFileAtomic(path, value, mode) {
161
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
162
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
163
+ const outputMode = mode === undefined ? existingFileMode(path) : mode;
164
+ writeFileSync(
165
+ temporary,
166
+ value,
167
+ outputMode === undefined ? undefined : { mode: outputMode },
168
+ );
169
+ chmodIfSet(temporary, outputMode);
170
+ renameSync(temporary, path);
171
+ chmodIfSet(path, outputMode);
172
+ }
173
+
174
+ export function publishLeaseFile(
175
+ lease,
176
+ path,
177
+ value,
178
+ { privateFile = true } = {},
179
+ ) {
180
+ assertLease(lease);
181
+ refreshLease(lease);
182
+ if (privateFile) writePrivateFile(path, value);
183
+ else writeFileAtomic(path, value);
184
+ assertLease(lease);
185
+ }
186
+
187
+ function active(path, now) {
188
+ return leaseIsActive(`${path}.lock`, now);
189
+ }
190
+
191
+ function sameEntry(left, right) {
192
+ return (
193
+ left.dev === right.dev &&
194
+ left.ino === right.ino &&
195
+ left.size === right.size &&
196
+ left.mtimeMs === right.mtimeMs
197
+ );
198
+ }
199
+
200
+ function fileSize(path) {
201
+ try {
202
+ return statSync(path).size;
203
+ } catch (error) {
204
+ if (error?.code !== 'ENOENT') throw error;
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ function removedCacheEntry() {
210
+ return { removed: true, retainedActive: false, size: 0 };
211
+ }
212
+
213
+ function cacheEntryState(entry, removedPath, now) {
214
+ return {
215
+ changed: !sameEntry(entry, statSync(removedPath)),
216
+ retainedActive: active(entry.path, now),
217
+ replaced: typeof fileSize(entry.path) === 'number',
218
+ };
219
+ }
220
+
221
+ function restoreCacheEntry(entry, removedPath, retainedActive) {
222
+ restoreLease(entry.path, removedPath);
223
+ return {
224
+ removed: false,
225
+ retainedActive,
226
+ size: fileSize(entry.path) ?? 0,
227
+ };
228
+ }
229
+
230
+ export function removeCacheEntry(entry, now = Date.now()) {
231
+ const removedPath = `${entry.path}.remove-${randomUUID()}`;
232
+ if (!renameLease(entry.path, removedPath)) return removedCacheEntry();
233
+ const state = cacheEntryState(entry, removedPath, now);
234
+ if ([state.changed, state.retainedActive, state.replaced].includes(true)) {
235
+ return restoreCacheEntry(entry, removedPath, state.retainedActive);
236
+ }
237
+ rmSync(removedPath, { force: true });
238
+ return removedCacheEntry();
239
+ }
240
+
241
+ function entries(cacheRoot) {
242
+ return files(join(cacheRoot, 'summaries'))
243
+ .filter((path) => path.endsWith('.json'))
244
+ .map((path) => ({ path, ...statSync(path) }));
245
+ }
246
+
247
+ function activeLeases(cacheRoot, now) {
248
+ return files(join(cacheRoot, 'summaries')).filter(
249
+ (path) => path.endsWith('.json.lock') && leaseIsActive(path, now),
250
+ );
251
+ }
252
+
253
+ export function cacheStatus({ cacheRoot = defaultCacheRoot(), now = Date.now() } = {}) {
254
+ const cached = entries(cacheRoot);
255
+ const bytes = cached.reduce((sum, entry) => sum + entry.size, 0);
256
+ const ages = cached.map((entry) => now - entry.mtimeMs);
257
+ return {
258
+ location: cacheRoot,
259
+ entries: cached.length,
260
+ bytes,
261
+ ageMs: ages.length ? Math.max(...ages) : 0,
262
+ active: activeLeases(cacheRoot, now).length,
263
+ };
264
+ }
265
+
266
+ function shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes }) {
267
+ const overAge =
268
+ maxAgeMs !== undefined && now - entry.mtimeMs > maxAgeMs;
269
+ const overSize = maxBytes !== undefined && bytes > maxBytes;
270
+ return overAge || overSize;
271
+ }
272
+
273
+ function applyPruneResult(entry, result, removed, retainedActive) {
274
+ if (result.removed) {
275
+ removed.push(entry.path);
276
+ return -entry.size;
277
+ }
278
+ if (result.retainedActive) retainedActive.push(entry.path);
279
+ return result.size - entry.size;
280
+ }
281
+
282
+ export function pruneCache({ cacheRoot = defaultCacheRoot(), maxAgeMs, maxBytes, now = Date.now() } = {}) {
283
+ const cached = entries(cacheRoot).sort((a, b) => a.mtimeMs - b.mtimeMs);
284
+ let bytes = cached.reduce((sum, entry) => sum + entry.size, 0);
285
+ const removed = [];
286
+ const retainedActive = [];
287
+ for (const entry of cached) {
288
+ if (!shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes })) continue;
289
+ const result = removeCacheEntry(entry, now);
290
+ bytes += applyPruneResult(entry, result, removed, retainedActive);
291
+ }
292
+ return { removed, retainedActive, bytes };
293
+ }
294
+
295
+ export function clearCache(options) {
296
+ return pruneCache({ ...options, maxAgeMs: 0 });
297
+ }
298
+
299
+ export function formatCacheStatus(status) {
300
+ return `Location: ${status.location}\nSize: ${status.bytes} bytes\nOldest entry: ${Math.floor(status.ageMs / 1000)} seconds\nActive use: ${status.active} target${status.active === 1 ? '' : 's'}`;
301
+ }
@@ -1,38 +1,70 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { codingAgents } from './coding-agents.mjs';
4
-
5
- const valueOptions = new Set([
6
- '--repo',
7
- '--branch',
8
- '--pr',
9
- '--base',
10
- '--head',
11
- '--remote',
12
- '--summaries',
13
- '--output',
14
- '--cache-dir',
15
- '--codex-bin',
16
- '--model',
17
- '--reasoning',
18
- '--batch-size',
19
- '--jobs',
20
- '--port',
21
- ]);
22
- const flagOptions = new Set([
23
- '--help',
24
- '--version',
25
- '--agent',
26
- '--no-agent',
27
- '--force',
28
- '--worktree',
29
- ]);
30
- const pathOptions = new Set([
31
- '--summaries',
32
- '--output',
33
- '--cache-dir',
34
- '--codex-bin',
35
- ]);
3
+ import {
4
+ assertReasoningSupported,
5
+ codingAgents,
6
+ enabledCodingAgents,
7
+ } from './coding-agents.mjs';
8
+
9
+ function defineCliOptions(records) {
10
+ return Object.freeze(
11
+ Object.fromEntries(
12
+ Object.entries(records).map(([name, record]) => [
13
+ name,
14
+ Object.freeze(record),
15
+ ]),
16
+ ),
17
+ );
18
+ }
19
+
20
+ export const cliOptions = defineCliOptions({
21
+ '--repo': { kind: 'value' },
22
+ '--branch': { kind: 'value' },
23
+ '--pr': { kind: 'value' },
24
+ '--base': { kind: 'value' },
25
+ '--head': { kind: 'value' },
26
+ '--remote': { kind: 'value' },
27
+ '--summaries': { kind: 'value', path: true },
28
+ '--output': { kind: 'value', path: true },
29
+ '--cache-dir': { kind: 'value', path: true },
30
+ '--codex-bin': { kind: 'value', path: true },
31
+ '--support-record-file': { kind: 'value', path: true },
32
+ '--model': { kind: 'value' },
33
+ '--reasoning': { kind: 'value' },
34
+ '--batch-size': { kind: 'value', default: 12, min: 1, max: 50 },
35
+ '--jobs': { kind: 'value', default: 3, min: 1, max: 8 },
36
+ '--port': { kind: 'value', default: 2299, min: 0, max: 65_535 },
37
+ '--host': { kind: 'value', default: 'localhost' },
38
+ '--help': { kind: 'flag' },
39
+ '--version': { kind: 'flag' },
40
+ '--agent': { kind: 'agent' },
41
+ '--no-agent': { kind: 'no-agent' },
42
+ '--force': { kind: 'flag' },
43
+ '--worktree': { kind: 'flag' },
44
+ '--no-browser': { kind: 'flag' },
45
+ '--support-record': { kind: 'flag' },
46
+ });
47
+
48
+ const valueOptions = new Set(
49
+ Object.entries(cliOptions)
50
+ .filter(([, record]) => record.kind === 'value')
51
+ .map(([name]) => name),
52
+ );
53
+ const flagOptions = new Set(
54
+ Object.entries(cliOptions)
55
+ .filter(([, record]) => record.kind === 'flag')
56
+ .map(([name]) => name),
57
+ );
58
+ const pathOptions = new Set(
59
+ Object.entries(cliOptions)
60
+ .filter(([, record]) => record.path)
61
+ .map(([name]) => name),
62
+ );
63
+
64
+ const batchSizeOption = cliOptions['--batch-size'];
65
+ const jobsOption = cliOptions['--jobs'];
66
+ const portOption = cliOptions['--port'];
67
+ const hostOption = cliOptions['--host'];
36
68
 
37
69
  export const helpText = `Usage: diffsplain [REPO] [options]
38
70
 
@@ -40,7 +72,9 @@ Show the current checkout against its default branch:
40
72
  diffsplain
41
73
 
42
74
  Commands:
43
- doctor Check Git, GitHub CLI, and coding agents
75
+ doctor [--json] [--deep]
76
+ Check review, agent, and pull request capabilities
77
+ cache Show or prune saved agent notes
44
78
 
45
79
  Targets:
46
80
  --branch NAME Show a remote branch against its default branch
@@ -52,24 +86,39 @@ Targets:
52
86
  Options:
53
87
  --repo PATH|URL|OWNER/NAME
54
88
  Repo to review (default: current repo)
55
- --agent NAME Use codex, claude, copilot, cursor, or opencode
89
+ --agent NAME Use codex, claude, copilot, or opencode
56
90
  --no-agent Do not write agent notes
57
91
  --model NAME Model for agent notes
58
92
  --reasoning LEVEL Agent reasoning effort when supported
59
- --batch-size COUNT Maximum files per agent pass (default: 12)
60
- --jobs COUNT Agent passes to run at once (default: 3)
93
+ --batch-size COUNT Maximum files per agent pass (default: ${batchSizeOption.default})
94
+ --jobs COUNT Agent passes to run at once (default: ${jobsOption.default})
61
95
  --force Regenerate all agent notes
96
+ --support-record Print a safe record if agent notes fail
97
+ --support-record-file FILE
98
+ Write a safe record if agent notes fail
62
99
  --remote NAME|URL Git remote (default: origin)
63
- --port NUMBER Local page port (default: 2299)
100
+ --port NUMBER Local page port (default: ${portOption.default})
101
+ --host ADDRESS Page bind address (default: ${hostOption.default})
102
+ --no-browser Do not open the page in a browser
103
+ --summaries FILE Saved agent-note file
104
+ --output FILE Live snapshot file
105
+ --cache-dir PATH Bare Git cache folder
106
+ --codex-bin PATH Codex executable
64
107
  -h, --help Show this help
65
108
  -v, --version Show the installed version
66
109
 
67
110
  Agent fallback:
68
- codex, claude, copilot, cursor, opencode
111
+ codex, claude, copilot, opencode
112
+
113
+ Cursor:
114
+ Disabled because Cursor Agent has no supported read-only, no-network,
115
+ no-tool mode
69
116
 
70
117
  Examples:
71
118
  diffsplain
72
119
  diffsplain doctor
120
+ diffsplain doctor --json
121
+ diffsplain cache status
73
122
  diffsplain --repo owner/project --pr 42
74
123
  diffsplain owner/project --branch feature/search
75
124
  diffsplain --agent claude`;
@@ -101,6 +150,19 @@ function githubRepoFromPullRequest(value) {
101
150
  }
102
151
  }
103
152
 
153
+ function validPullRequest(value) {
154
+ if (/^[1-9]\d*$/.test(value)) return true;
155
+ try {
156
+ const url = new URL(value);
157
+ return (
158
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
159
+ /^\/[^/]+\/[^/]+\/pull\/[1-9]\d*(?:\/|$)/.test(url.pathname)
160
+ );
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
104
166
  function remoteRepo(value, callerDirectory, pathExists) {
105
167
  if (pathExists(resolve(callerDirectory, value))) return undefined;
106
168
  if (
@@ -123,8 +185,16 @@ export function parseCliArgs(
123
185
  } = {},
124
186
  ) {
125
187
  if (rawArgs[0] === 'doctor') {
126
- if (rawArgs.length > 1) fail('doctor does not take arguments or options');
127
- return { doctor: true };
188
+ const options = new Set(rawArgs.slice(1));
189
+ for (const option of options) {
190
+ if (!['--json', '--deep'].includes(option)) {
191
+ fail('doctor only accepts --json and --deep');
192
+ }
193
+ }
194
+ if (options.size !== rawArgs.length - 1) {
195
+ fail('doctor options can only be passed once');
196
+ }
197
+ return { doctor: { json: options.has('--json'), deep: options.has('--deep') } };
128
198
  }
129
199
 
130
200
  const options = new Map();
@@ -137,32 +207,38 @@ export function parseCliArgs(
137
207
  const argument = rawArgs[index];
138
208
  const parsed = splitOption(argument);
139
209
  if (!parsed) {
210
+ if (argument.startsWith('-')) fail(`Unknown option: ${argument}`);
211
+ if (!argument) fail('Repo cannot be empty');
140
212
  positionals.push(argument);
141
213
  continue;
142
214
  }
143
215
 
144
216
  if (parsed.name === '--agent') {
217
+ if (agentSet) fail('--agent was passed more than once');
145
218
  agentSet = true;
146
219
  if (parsed.value !== undefined) {
147
220
  if (!parsed.value) fail('--agent needs a value');
148
221
  agent = parsed.value;
149
222
  } else {
150
223
  const next = rawArgs[index + 1];
151
- if (next && !next.startsWith('-')) {
152
- agent = next;
153
- index += 1;
154
- }
224
+ if (!next || splitOption(next)) fail('--agent needs a value');
225
+ agent = next;
226
+ index += 1;
155
227
  }
156
228
  continue;
157
229
  }
158
230
 
159
231
  if (parsed.name === '--no-agent') {
232
+ if (noAgent) fail('--no-agent was passed more than once');
160
233
  if (parsed.value !== undefined) fail('--no-agent does not take a value');
161
234
  noAgent = true;
162
235
  continue;
163
236
  }
164
237
 
165
238
  if (flagOptions.has(parsed.name)) {
239
+ if (options.has(parsed.name)) {
240
+ fail(`${parsed.name} was passed more than once`);
241
+ }
166
242
  if (parsed.value !== undefined) {
167
243
  fail(`${parsed.name} does not take a value`);
168
244
  }
@@ -178,7 +254,7 @@ export function parseCliArgs(
178
254
  let value = parsed.value;
179
255
  if (value === undefined) {
180
256
  value = rawArgs[index + 1];
181
- if (!value || value.startsWith('--')) fail(`${parsed.name} needs a value`);
257
+ if (!value || splitOption(value)) fail(`${parsed.name} needs a value`);
182
258
  index += 1;
183
259
  }
184
260
  if (!value) fail(`${parsed.name} needs a value`);
@@ -192,9 +268,25 @@ export function parseCliArgs(
192
268
  fail('Pass the repo once, either as REPO or with --repo');
193
269
  }
194
270
  if (noAgent && agentSet) fail('--agent and --no-agent cannot be used together');
271
+ if (noAgent && options.has('--summaries')) {
272
+ fail('--no-agent cannot be used with --summaries');
273
+ }
274
+ if (
275
+ noAgent &&
276
+ (options.has('--support-record') ||
277
+ options.has('--support-record-file'))
278
+ ) {
279
+ fail('--no-agent cannot be used with a support record');
280
+ }
281
+ if (
282
+ options.has('--support-record') &&
283
+ options.has('--support-record-file')
284
+ ) {
285
+ fail('Pass either --support-record or --support-record-file, not both');
286
+ }
195
287
  if (!noAgent && agent && !codingAgents.includes(agent)) {
196
288
  fail(
197
- `Unsupported agent "${agent}". Choose ${codingAgents.join(', ')}.`,
289
+ `Unsupported agent "${agent}". Choose ${enabledCodingAgents.join(', ')}.`,
198
290
  );
199
291
  }
200
292
 
@@ -214,6 +306,9 @@ export function parseCliArgs(
214
306
  if (!branch && !pullRequest && !worktree && Boolean(base) !== Boolean(head)) {
215
307
  fail('--base and --head must be used together');
216
308
  }
309
+ if (pullRequest && !validPullRequest(pullRequest)) {
310
+ fail('--pr must be a positive number or a pull request URL');
311
+ }
217
312
 
218
313
  const repoArgument = positionals[0] || options.get('--repo');
219
314
  let remote = options.get('--remote');
@@ -263,14 +358,20 @@ export function parseCliArgs(
263
358
  }
264
359
  }
265
360
 
361
+ const feedArgs = [...commonArgs];
362
+ const supportRecordFile = options.get('--support-record-file');
363
+ if (supportRecordFile) {
364
+ feedArgs.push(
365
+ '--exclude-output',
366
+ resolve(callerDirectory, supportRecordFile),
367
+ );
368
+ }
266
369
  const agentArgs = [...commonArgs];
267
370
  if (options.has('--force')) agentArgs.push('--force');
268
371
  for (const name of [
269
372
  '--codex-bin',
270
373
  '--model',
271
374
  '--reasoning',
272
- '--batch-size',
273
- '--jobs',
274
375
  ]) {
275
376
  const value = options.get(name);
276
377
  if (value) {
@@ -280,6 +381,23 @@ export function parseCliArgs(
280
381
  );
281
382
  }
282
383
  }
384
+ if (!noAgent) {
385
+ for (const name of ['--batch-size', '--jobs']) {
386
+ agentArgs.push(
387
+ name,
388
+ options.get(name) || String(cliOptions[name].default),
389
+ );
390
+ }
391
+ }
392
+ if (supportRecordFile) {
393
+ agentArgs.push(
394
+ '--support-record-file',
395
+ resolve(callerDirectory, supportRecordFile),
396
+ );
397
+ }
398
+ if (options.has('--support-record')) {
399
+ agentArgs.push('--support-record');
400
+ }
283
401
 
284
402
  const reasoning = options.get('--reasoning');
285
403
  if (
@@ -292,36 +410,67 @@ export function parseCliArgs(
292
410
  '--reasoning must be minimal, low, medium, high, or xhigh',
293
411
  );
294
412
  }
413
+ if (reasoning && agent) assertReasoningSupported(agent, reasoning);
295
414
  const batchSize = options.get('--batch-size');
296
415
  if (
297
416
  batchSize &&
298
- (!/^[1-9]\d*$/.test(batchSize) || Number(batchSize) > 50)
417
+ (
418
+ !/^[1-9]\d*$/.test(batchSize) ||
419
+ Number(batchSize) < batchSizeOption.min ||
420
+ Number(batchSize) > batchSizeOption.max
421
+ )
299
422
  ) {
300
- fail('--batch-size must be a number from 1 to 50');
423
+ fail(
424
+ `--batch-size must be a number from ${batchSizeOption.min} to ${batchSizeOption.max}`,
425
+ );
301
426
  }
302
427
  const jobs = options.get('--jobs');
303
428
  if (
304
429
  jobs &&
305
- (!/^[1-9]\d*$/.test(jobs) || Number(jobs) > 8)
430
+ (
431
+ !/^[1-9]\d*$/.test(jobs) ||
432
+ Number(jobs) < jobsOption.min ||
433
+ Number(jobs) > jobsOption.max
434
+ )
306
435
  ) {
307
- fail('--jobs must be a number from 1 to 8');
436
+ fail(
437
+ `--jobs must be a number from ${jobsOption.min} to ${jobsOption.max}`,
438
+ );
308
439
  }
309
440
 
310
- const portValue = options.get('--port') || '2299';
311
- if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
312
- fail('--port must be a number from 0 to 65535');
441
+ const portValue = options.get('--port') || String(portOption.default);
442
+ if (
443
+ !/^\d+$/.test(portValue) ||
444
+ Number(portValue) < portOption.min ||
445
+ Number(portValue) > portOption.max
446
+ ) {
447
+ fail(
448
+ `--port must be a number from ${portOption.min} to ${portOption.max}`,
449
+ );
313
450
  }
451
+ const host = (options.get('--host') || hostOption.default).replace(
452
+ /^\[|\]$/g,
453
+ '',
454
+ );
314
455
 
315
456
  return {
316
457
  help: false,
317
458
  version: false,
318
459
  agentEnabled: !noAgent,
319
460
  agent,
461
+ model: options.get('--model'),
462
+ reasoning,
320
463
  codexBin: options.get('--codex-bin'),
321
- feedArgs: commonArgs,
464
+ feedArgs,
322
465
  agentArgs,
466
+ supportRecord: options.has('--support-record'),
467
+ supportRecordFile: supportRecordFile
468
+ ? resolve(callerDirectory, supportRecordFile)
469
+ : undefined,
323
470
  port: Number(portValue),
324
471
  portWasPassed: options.has('--port'),
472
+ host,
473
+ browserEnabled: !options.has('--no-browser'),
325
474
  forceSummaryRegeneration: options.has('--force'),
326
475
  };
327
476
  }