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.
- package/README.md +12 -4
- package/dist/assets/{index-d0QMfM_f.js → index-CwEmcR7p.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/scripts/build-diff-data.mjs +34 -7
- package/scripts/cli-args.mjs +22 -3
- package/scripts/coding-agents.mjs +45 -6
- package/scripts/doctor.mjs +119 -0
- package/scripts/generate-summaries.mjs +333 -94
- package/scripts/present.mjs +20 -3
- package/scripts/serve-built.mjs +28 -0
|
@@ -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
|
|
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
|
|
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
|
-
)
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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 [
|
package/scripts/cli-args.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
3
|
+
import {
|
|
4
|
+
basename,
|
|
5
|
+
delimiter,
|
|
6
|
+
dirname,
|
|
7
|
+
isAbsolute,
|
|
8
|
+
join,
|
|
9
|
+
} from 'node:path';
|
|
4
10
|
|
|
5
|
-
export const codingAgents = [
|
|
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
|
|
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
|
|
54
|
+
return join(directory, `${command}${extension}`);
|
|
43
55
|
}
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
|
-
return
|
|
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
|
+
}
|