diffsplain 0.2.1 → 0.4.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.
@@ -79,6 +79,7 @@ const cacheRoot = cacheOption
79
79
  : resolve(projectRoot, '.cache/git');
80
80
  const remoteMode = Boolean(prOption || branchOption);
81
81
  const watching = has('--watch');
82
+ const ignoreSummaryWatch = has('--ignore-summary-watch');
82
83
 
83
84
  if (prOption && branchOption) fail('--pr and --branch cannot be used together');
84
85
  if (prOption && (baseOption || headOption)) fail('--pr cannot be used with --base or --head');
@@ -706,7 +707,25 @@ function resolveTarget() {
706
707
  return resolveLocalTarget();
707
708
  }
708
709
 
709
- function filePatch(file, target) {
710
+ function trackedPatches(files, target) {
711
+ const raw = target.runGit([
712
+ 'diff',
713
+ '--no-ext-diff',
714
+ '--no-textconv',
715
+ '--binary',
716
+ '--find-renames',
717
+ ...target.range,
718
+ ]);
719
+ const patches = raw
720
+ .split(/(?=^diff --git )/m)
721
+ .filter((patch) => patch.startsWith('diff --git '));
722
+ if (patches.length !== files.length) return undefined;
723
+ return new Map(
724
+ files.map((file, index) => [file.path, patches[index]]),
725
+ );
726
+ }
727
+
728
+ function filePatch(file, target, patches) {
710
729
  if (file.untracked) {
711
730
  return runRepoWithDiffExit([
712
731
  'diff',
@@ -719,6 +738,8 @@ function filePatch(file, target) {
719
738
  file.path,
720
739
  ]);
721
740
  }
741
+ const combined = patches?.get(file.path);
742
+ if (combined !== undefined) return combined;
722
743
  const pathspec = file.oldPath ? [file.oldPath, file.path] : [file.path];
723
744
  return target.runGit([
724
745
  'diff',
@@ -766,7 +787,7 @@ function build() {
766
787
  const target = resolveTarget();
767
788
  const remoteRepository = githubRepository(target.remote?.url);
768
789
  const summaryDoc = readJson(summariesPath, {}) || {};
769
- const nameStatus = parseNameStatus(
790
+ const allTrackedFiles = parseNameStatus(
770
791
  target.runGit([
771
792
  'diff',
772
793
  '--no-ext-diff',
@@ -776,7 +797,10 @@ function build() {
776
797
  '--find-renames',
777
798
  ...target.range,
778
799
  ]),
779
- ).filter((file) => !excludedPaths.has(file.path));
800
+ );
801
+ const nameStatus = allTrackedFiles.filter(
802
+ (file) => !excludedPaths.has(file.path),
803
+ );
780
804
  const numstat = parseNumstat(
781
805
  target.runGit([
782
806
  'diff',
@@ -788,6 +812,7 @@ function build() {
788
812
  ...target.range,
789
813
  ]),
790
814
  );
815
+ const patches = trackedPatches(allTrackedFiles, target);
791
816
 
792
817
  if (target.kind === 'worktree' || target.kind === 'checkout') {
793
818
  const trackedPaths = new Set(nameStatus.map((file) => file.path));
@@ -815,7 +840,7 @@ function build() {
815
840
  deletions: 0,
816
841
  isBinary: false,
817
842
  };
818
- const patch = filePatch(file, target);
843
+ const patch = filePatch(file, target, patches);
819
844
  const binary =
820
845
  stat.isBinary ||
821
846
  patch.includes('Binary files ') ||
@@ -954,9 +979,11 @@ function build() {
954
979
 
955
980
  function fingerprint() {
956
981
  let summariesTime = '';
957
- try {
958
- summariesTime = String(statSync(summariesPath).mtimeMs);
959
- } catch {}
982
+ if (!ignoreSummaryWatch) {
983
+ try {
984
+ summariesTime = String(statSync(summariesPath).mtimeMs);
985
+ } catch {}
986
+ }
960
987
  if (remoteMode) return summariesTime;
961
988
  if (baseOption && headOption) {
962
989
  return [
@@ -16,6 +16,7 @@ const valueOptions = new Set([
16
16
  '--model',
17
17
  '--reasoning',
18
18
  '--batch-size',
19
+ '--jobs',
19
20
  '--port',
20
21
  ]);
21
22
  const flagOptions = new Set([
@@ -38,6 +39,9 @@ export const helpText = `Usage: diffsplain [REPO] [options]
38
39
  Show the current checkout against its default branch:
39
40
  diffsplain
40
41
 
42
+ Commands:
43
+ doctor Check Git, GitHub CLI, and coding agents
44
+
41
45
  Targets:
42
46
  --branch NAME Show a remote branch against its default branch
43
47
  --pr NUMBER|URL Show a GitHub pull request
@@ -48,11 +52,12 @@ Targets:
48
52
  Options:
49
53
  --repo PATH|URL|OWNER/NAME
50
54
  Repo to review (default: current repo)
51
- --agent NAME Use codex, claude, copilot, or opencode
55
+ --agent NAME Use codex, claude, copilot, cursor, or opencode
52
56
  --no-agent Do not write agent notes
53
57
  --model NAME Model for agent notes
54
58
  --reasoning LEVEL Agent reasoning effort when supported
55
- --batch-size COUNT Files per agent pass (default: 4)
59
+ --batch-size COUNT Maximum files per agent pass (default: 12)
60
+ --jobs COUNT Agent passes to run at once (default: 3)
56
61
  --force Regenerate all agent notes
57
62
  --remote NAME|URL Git remote (default: origin)
58
63
  --port NUMBER Local page port (default: 2299)
@@ -60,10 +65,11 @@ Options:
60
65
  -v, --version Show the installed version
61
66
 
62
67
  Agent fallback:
63
- codex, claude, copilot, opencode
68
+ codex, claude, copilot, cursor, opencode
64
69
 
65
70
  Examples:
66
71
  diffsplain
72
+ diffsplain doctor
67
73
  diffsplain --repo owner/project --pr 42
68
74
  diffsplain owner/project --branch feature/search
69
75
  diffsplain --agent claude`;
@@ -116,6 +122,11 @@ export function parseCliArgs(
116
122
  pathExists = existsSync,
117
123
  } = {},
118
124
  ) {
125
+ if (rawArgs[0] === 'doctor') {
126
+ if (rawArgs.length > 1) fail('doctor does not take arguments or options');
127
+ return { doctor: true };
128
+ }
129
+
119
130
  const options = new Map();
120
131
  const positionals = [];
121
132
  let agent;
@@ -259,6 +270,7 @@ export function parseCliArgs(
259
270
  '--model',
260
271
  '--reasoning',
261
272
  '--batch-size',
273
+ '--jobs',
262
274
  ]) {
263
275
  const value = options.get(name);
264
276
  if (value) {
@@ -287,6 +299,13 @@ export function parseCliArgs(
287
299
  ) {
288
300
  fail('--batch-size must be a number from 1 to 50');
289
301
  }
302
+ const jobs = options.get('--jobs');
303
+ if (
304
+ jobs &&
305
+ (!/^[1-9]\d*$/.test(jobs) || Number(jobs) > 8)
306
+ ) {
307
+ fail('--jobs must be a number from 1 to 8');
308
+ }
290
309
 
291
310
  const portValue = options.get('--port') || '2299';
292
311
  if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
@@ -1,8 +1,20 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { access } from 'node:fs/promises';
3
- import { delimiter, dirname, isAbsolute, join } from 'node:path';
3
+ import {
4
+ basename,
5
+ delimiter,
6
+ dirname,
7
+ isAbsolute,
8
+ join,
9
+ } from 'node:path';
4
10
 
5
- export const codingAgents = ['codex', 'claude', 'copilot', 'opencode'];
11
+ export const codingAgents = [
12
+ 'codex',
13
+ 'claude',
14
+ 'copilot',
15
+ 'cursor',
16
+ 'opencode',
17
+ ];
6
18
 
7
19
  async function executable(path) {
8
20
  try {
@@ -16,7 +28,7 @@ async function executable(path) {
16
28
  }
17
29
  }
18
30
 
19
- export async function commandAvailable(
31
+ export async function findCommand(
20
32
  command,
21
33
  {
22
34
  env = process.env,
@@ -28,7 +40,7 @@ export async function commandAvailable(
28
40
  command.includes('/') ||
29
41
  command.includes('\\')
30
42
  ) {
31
- return executable(command);
43
+ return (await executable(command)) ? command : undefined;
32
44
  }
33
45
 
34
46
  const extensions =
@@ -39,11 +51,15 @@ export async function commandAvailable(
39
51
  for (const directory of directories) {
40
52
  for (const extension of extensions) {
41
53
  if (await executable(join(directory, `${command}${extension}`))) {
42
- return true;
54
+ return join(directory, `${command}${extension}`);
43
55
  }
44
56
  }
45
57
  }
46
- return false;
58
+ return undefined;
59
+ }
60
+
61
+ export async function commandAvailable(command, options) {
62
+ return Boolean(await findCommand(command, options));
47
63
  }
48
64
 
49
65
  export async function selectCodingAgent(
@@ -78,6 +94,7 @@ export function codingAgentBinary(
78
94
  } = {},
79
95
  ) {
80
96
  if (agent === 'codex') return codexBin || env.CODEX_BIN || agent;
97
+ if (agent === 'cursor') return env.CURSOR_BIN || 'cursor-agent';
81
98
  return env[`${agent.toUpperCase()}_BIN`] || agent;
82
99
  }
83
100
 
@@ -117,6 +134,14 @@ export function parseAgentResponse(agent, stdout) {
117
134
  if (parts.length) return parseJsonText(parts.join(''), 'OpenCode');
118
135
  }
119
136
 
137
+ if (agent === 'cursor') {
138
+ const envelope = parseJsonText(stdout, 'Cursor');
139
+ if (typeof envelope?.result === 'string') {
140
+ return parseJsonText(envelope.result, 'Cursor');
141
+ }
142
+ return envelope;
143
+ }
144
+
120
145
  const label = agent === 'copilot' ? 'Copilot' : 'Codex';
121
146
  return parseJsonText(stdout, label);
122
147
  }
@@ -193,6 +218,20 @@ export function agentCommand({
193
218
  return { command: binary, args, input: 'none' };
194
219
  }
195
220
 
221
+ if (agent === 'cursor') {
222
+ const args = ['--print', '--output-format', 'json'];
223
+ if (model) args.push('--model', model);
224
+ args.push(
225
+ `${prompt}\n\nRead the snapshot from @${basename(inputPath)}. Return only JSON that matches this schema:\n${JSON.stringify(schema)}`,
226
+ );
227
+ return {
228
+ command: binary,
229
+ args,
230
+ input: 'none',
231
+ cwd: dirname(inputPath),
232
+ };
233
+ }
234
+
196
235
  const args = [
197
236
  'run',
198
237
  '--pure',
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ codingAgentBinary,
4
+ codingAgents,
5
+ findCommand,
6
+ } from './coding-agents.mjs';
7
+
8
+ const agentLabels = {
9
+ codex: 'Codex',
10
+ claude: 'Claude',
11
+ copilot: 'Copilot',
12
+ cursor: 'Cursor',
13
+ opencode: 'OpenCode',
14
+ };
15
+
16
+ function firstLine(value) {
17
+ return value
18
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
19
+ .split('\n')
20
+ .map((line) => line.trim())
21
+ .find(Boolean);
22
+ }
23
+
24
+ function commandVersion(command) {
25
+ const result = spawnSync(command, ['--version'], {
26
+ encoding: 'utf8',
27
+ timeout: 5_000,
28
+ windowsHide: true,
29
+ });
30
+ if (result.error || result.status !== 0) return undefined;
31
+ return firstLine(`${result.stdout || ''}\n${result.stderr || ''}`);
32
+ }
33
+
34
+ async function inspectDependency(
35
+ label,
36
+ command,
37
+ {
38
+ env,
39
+ platform,
40
+ },
41
+ ) {
42
+ const path = await findCommand(command, { env, platform });
43
+ if (!path) return { label, command, installed: false };
44
+ return {
45
+ label,
46
+ command,
47
+ installed: true,
48
+ path,
49
+ version: commandVersion(path),
50
+ };
51
+ }
52
+
53
+ function dependencyLine(dependency) {
54
+ const label = dependency.label.padEnd(9);
55
+ if (!dependency.installed) {
56
+ return ` ✗ ${label} not found (${dependency.command})`;
57
+ }
58
+ const mark = dependency.version ? '✓' : '!';
59
+ const version = dependency.version || 'version unavailable';
60
+ return ` ${mark} ${label} ${version} (${dependency.path})`;
61
+ }
62
+
63
+ function joinedAgentNames(agents) {
64
+ return agents.map((agent) => agent.label).join(', ');
65
+ }
66
+
67
+ export async function doctorReport({
68
+ env = process.env,
69
+ platform = process.platform,
70
+ architecture = process.arch,
71
+ nodeVersion = process.version,
72
+ nodePath = process.execPath,
73
+ } = {}) {
74
+ const [git, gh, ...agents] = await Promise.all([
75
+ inspectDependency('Git', 'git', { env, platform }),
76
+ inspectDependency('gh', 'gh', { env, platform }),
77
+ ...codingAgents.map((agent) =>
78
+ inspectDependency(
79
+ agentLabels[agent],
80
+ codingAgentBinary(agent, { env }),
81
+ { env, platform },
82
+ ),
83
+ ),
84
+ ]);
85
+ const installedAgents = agents.filter((agent) => agent.installed);
86
+ const agentCount = installedAgents.length
87
+ ? `${installedAgents.length} installed`
88
+ : 'none installed';
89
+ const lines = [
90
+ 'Diffsplain doctor',
91
+ '',
92
+ 'Dependencies',
93
+ ` ✓ ${'Node'.padEnd(9)} ${nodeVersion} (${nodePath})`,
94
+ dependencyLine(git),
95
+ dependencyLine(gh),
96
+ '',
97
+ `Coding agents (${agentCount})`,
98
+ ...agents.map(dependencyLine),
99
+ '',
100
+ 'Status',
101
+ git.installed
102
+ ? ' ✓ Git reviews are ready.'
103
+ : ' ✗ Git is not installed.',
104
+ installedAgents.length
105
+ ? ` ✓ Agent notes are ready with ${joinedAgentNames(installedAgents)}.`
106
+ : ' ✗ No supported coding agent is installed.',
107
+ gh.installed
108
+ ? ' ✓ Pull request lookup is ready with gh.'
109
+ : ' ✗ gh is not installed; pull request lookup is unavailable.',
110
+ ` Platform: ${platform} ${architecture}`,
111
+ ];
112
+ if (!installedAgents.length) {
113
+ lines.push(' Use --no-agent to run without agent notes.');
114
+ }
115
+ return {
116
+ text: lines.join('\n'),
117
+ ready: git.installed && installedAgents.length > 0,
118
+ };
119
+ }