diffsplain 0.1.0 → 0.2.1
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 +13 -9
- package/package.json +10 -6
- package/scripts/cli-args.mjs +29 -14
- package/scripts/coding-agents.mjs +212 -0
- package/scripts/generate-summaries.mjs +62 -45
- package/scripts/present.mjs +88 -5
- package/scripts/release.mjs +49 -0
- package/scripts/serve-built.mjs +29 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Diffsplain
|
|
2
2
|
|
|
3
|
-
Review a Git diff one file at a time, with a short
|
|
3
|
+
Review a Git diff one file at a time, with a short coding agent note beside each
|
|
4
4
|
patch.
|
|
5
5
|
|
|
6
6
|
## Use
|
|
@@ -11,9 +11,11 @@ Run from a Git checkout:
|
|
|
11
11
|
npx diffsplain
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The command opens
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
The command opens a local page and compares the checkout with its default
|
|
15
|
+
branch. It starts at port `2299` and uses the next free port when needed. You
|
|
16
|
+
need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, or OpenCode
|
|
17
|
+
CLI. Diffsplain tries them in that order. Pull requests also need a signed-in
|
|
18
|
+
GitHub CLI.
|
|
17
19
|
|
|
18
20
|
Common targets:
|
|
19
21
|
|
|
@@ -33,13 +35,15 @@ Arguments:
|
|
|
33
35
|
| `--branch NAME` | Compare a remote branch with its default branch. |
|
|
34
36
|
| `--worktree` | Review tracked and untracked changes against `HEAD`. |
|
|
35
37
|
| `--base REF --head REF` | Review an exact local range. |
|
|
36
|
-
| `--agent
|
|
37
|
-
| `--model NAME` | Choose the
|
|
38
|
+
| `--agent NAME`, `--no-agent` | Choose a coding agent, or show a plain diff. |
|
|
39
|
+
| `--model NAME` | Choose the model used for notes. |
|
|
38
40
|
| `--reasoning LEVEL` | Set `minimal`, `low`, `medium`, `high`, or `xhigh`. |
|
|
39
|
-
| `--batch-size COUNT` | Set files per
|
|
41
|
+
| `--batch-size COUNT` | Set files per agent pass. The default is `4`. |
|
|
42
|
+
| `--force` | Regenerate all agent notes instead of using cached notes. |
|
|
40
43
|
| `--remote NAME\|URL` | Choose the Git remote. The default is `origin`. |
|
|
41
|
-
| `--port NUMBER` | Choose
|
|
42
|
-
| `--help` | Show command help. |
|
|
44
|
+
| `--port NUMBER` | Choose an exact local port. The default starts at `2299`. |
|
|
45
|
+
| `-h`, `--help` | Show command help. |
|
|
46
|
+
| `-v`, `--version` | Show the installed version. |
|
|
43
47
|
|
|
44
48
|
## Local development
|
|
45
49
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diffsplain",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Review Git diffs one file at a time with
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Review Git diffs one file at a time with coding agent notes beside each patch.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
7
|
+
"claude",
|
|
8
|
+
"copilot",
|
|
9
|
+
"opencode",
|
|
7
10
|
"diff",
|
|
8
11
|
"git",
|
|
9
12
|
"github",
|
|
@@ -12,11 +15,11 @@
|
|
|
12
15
|
],
|
|
13
16
|
"repository": {
|
|
14
17
|
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/itsjling/
|
|
18
|
+
"url": "git+https://github.com/itsjling/diffsplain.git"
|
|
16
19
|
},
|
|
17
|
-
"homepage": "https://github.com/itsjling/
|
|
20
|
+
"homepage": "https://github.com/itsjling/diffsplain#readme",
|
|
18
21
|
"bugs": {
|
|
19
|
-
"url": "https://github.com/itsjling/
|
|
22
|
+
"url": "https://github.com/itsjling/diffsplain/issues"
|
|
20
23
|
},
|
|
21
24
|
"bin": {
|
|
22
25
|
"diffsplain": "scripts/present.mjs"
|
|
@@ -29,12 +32,13 @@
|
|
|
29
32
|
"node": ">=22.13.0"
|
|
30
33
|
},
|
|
31
34
|
"scripts": {
|
|
32
|
-
"dev": "vite --port
|
|
35
|
+
"dev": "vite --port 2299",
|
|
33
36
|
"docs:dev": "cd docs && blume dev",
|
|
34
37
|
"docs:build": "cd docs && blume build",
|
|
35
38
|
"docs:check": "cd docs && blume check && blume validate",
|
|
36
39
|
"docs:preview": "cd docs && blume preview",
|
|
37
40
|
"present": "node scripts/present.mjs",
|
|
41
|
+
"release": "node scripts/release.mjs",
|
|
38
42
|
"summarize": "node scripts/generate-summaries.mjs",
|
|
39
43
|
"snapshot": "node scripts/build-diff-data.mjs",
|
|
40
44
|
"watch:diff": "node scripts/build-diff-data.mjs --watch",
|
package/scripts/cli-args.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { codingAgents } from './coding-agents.mjs';
|
|
3
4
|
|
|
4
5
|
const valueOptions = new Set([
|
|
5
6
|
'--repo',
|
|
@@ -19,8 +20,10 @@ const valueOptions = new Set([
|
|
|
19
20
|
]);
|
|
20
21
|
const flagOptions = new Set([
|
|
21
22
|
'--help',
|
|
23
|
+
'--version',
|
|
22
24
|
'--agent',
|
|
23
25
|
'--no-agent',
|
|
26
|
+
'--force',
|
|
24
27
|
'--worktree',
|
|
25
28
|
]);
|
|
26
29
|
const pathOptions = new Set([
|
|
@@ -45,26 +48,33 @@ Targets:
|
|
|
45
48
|
Options:
|
|
46
49
|
--repo PATH|URL|OWNER/NAME
|
|
47
50
|
Repo to review (default: current repo)
|
|
48
|
-
--agent
|
|
51
|
+
--agent NAME Use codex, claude, copilot, or opencode
|
|
49
52
|
--no-agent Do not write agent notes
|
|
50
|
-
--model NAME
|
|
51
|
-
--reasoning LEVEL
|
|
53
|
+
--model NAME Model for agent notes
|
|
54
|
+
--reasoning LEVEL Agent reasoning effort when supported
|
|
52
55
|
--batch-size COUNT Files per agent pass (default: 4)
|
|
56
|
+
--force Regenerate all agent notes
|
|
53
57
|
--remote NAME|URL Git remote (default: origin)
|
|
54
|
-
--port NUMBER Local page port (default:
|
|
55
|
-
--help
|
|
58
|
+
--port NUMBER Local page port (default: 2299)
|
|
59
|
+
-h, --help Show this help
|
|
60
|
+
-v, --version Show the installed version
|
|
61
|
+
|
|
62
|
+
Agent fallback:
|
|
63
|
+
codex, claude, copilot, opencode
|
|
56
64
|
|
|
57
65
|
Examples:
|
|
58
66
|
diffsplain
|
|
59
67
|
diffsplain --repo owner/project --pr 42
|
|
60
68
|
diffsplain owner/project --branch feature/search
|
|
61
|
-
diffsplain --agent
|
|
69
|
+
diffsplain --agent claude`;
|
|
62
70
|
|
|
63
71
|
function fail(message) {
|
|
64
72
|
throw new Error(message);
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
function splitOption(argument) {
|
|
76
|
+
if (argument === '-h') return { name: '--help', value: undefined };
|
|
77
|
+
if (argument === '-v') return { name: '--version', value: undefined };
|
|
68
78
|
if (!argument.startsWith('--')) return undefined;
|
|
69
79
|
const separator = argument.indexOf('=');
|
|
70
80
|
if (separator === -1) return { name: argument, value: undefined };
|
|
@@ -108,7 +118,7 @@ export function parseCliArgs(
|
|
|
108
118
|
) {
|
|
109
119
|
const options = new Map();
|
|
110
120
|
const positionals = [];
|
|
111
|
-
let agent
|
|
121
|
+
let agent;
|
|
112
122
|
let agentSet = false;
|
|
113
123
|
let noAgent = false;
|
|
114
124
|
|
|
@@ -121,18 +131,15 @@ export function parseCliArgs(
|
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
if (parsed.name === '--agent') {
|
|
134
|
+
agentSet = true;
|
|
124
135
|
if (parsed.value !== undefined) {
|
|
125
136
|
if (!parsed.value) fail('--agent needs a value');
|
|
126
137
|
agent = parsed.value;
|
|
127
|
-
agentSet = true;
|
|
128
138
|
} else {
|
|
129
139
|
const next = rawArgs[index + 1];
|
|
130
140
|
if (next && !next.startsWith('-')) {
|
|
131
141
|
agent = next;
|
|
132
|
-
agentSet = true;
|
|
133
142
|
index += 1;
|
|
134
|
-
} else {
|
|
135
|
-
agentSet = true;
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
continue;
|
|
@@ -168,13 +175,16 @@ export function parseCliArgs(
|
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
if (options.has('--help')) return { help: true };
|
|
178
|
+
if (options.has('--version')) return { version: true };
|
|
171
179
|
if (positionals.length > 1) fail('Pass at most one repo');
|
|
172
180
|
if (positionals.length && options.has('--repo')) {
|
|
173
181
|
fail('Pass the repo once, either as REPO or with --repo');
|
|
174
182
|
}
|
|
175
183
|
if (noAgent && agentSet) fail('--agent and --no-agent cannot be used together');
|
|
176
|
-
if (!noAgent && agent
|
|
177
|
-
fail(
|
|
184
|
+
if (!noAgent && agent && !codingAgents.includes(agent)) {
|
|
185
|
+
fail(
|
|
186
|
+
`Unsupported agent "${agent}". Choose ${codingAgents.join(', ')}.`,
|
|
187
|
+
);
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
const branch = options.get('--branch');
|
|
@@ -243,6 +253,7 @@ export function parseCliArgs(
|
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
const agentArgs = [...commonArgs];
|
|
256
|
+
if (options.has('--force')) agentArgs.push('--force');
|
|
246
257
|
for (const name of [
|
|
247
258
|
'--codex-bin',
|
|
248
259
|
'--model',
|
|
@@ -277,17 +288,21 @@ export function parseCliArgs(
|
|
|
277
288
|
fail('--batch-size must be a number from 1 to 50');
|
|
278
289
|
}
|
|
279
290
|
|
|
280
|
-
const portValue = options.get('--port') || '
|
|
291
|
+
const portValue = options.get('--port') || '2299';
|
|
281
292
|
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
|
|
282
293
|
fail('--port must be a number from 0 to 65535');
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
return {
|
|
286
297
|
help: false,
|
|
298
|
+
version: false,
|
|
287
299
|
agentEnabled: !noAgent,
|
|
288
300
|
agent,
|
|
301
|
+
codexBin: options.get('--codex-bin'),
|
|
289
302
|
feedArgs: commonArgs,
|
|
290
303
|
agentArgs,
|
|
291
304
|
port: Number(portValue),
|
|
305
|
+
portWasPassed: options.has('--port'),
|
|
306
|
+
forceSummaryRegeneration: options.has('--force'),
|
|
292
307
|
};
|
|
293
308
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const codingAgents = ['codex', 'claude', 'copilot', 'opencode'];
|
|
6
|
+
|
|
7
|
+
async function executable(path) {
|
|
8
|
+
try {
|
|
9
|
+
await access(
|
|
10
|
+
path,
|
|
11
|
+
process.platform === 'win32' ? constants.F_OK : constants.X_OK,
|
|
12
|
+
);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function commandAvailable(
|
|
20
|
+
command,
|
|
21
|
+
{
|
|
22
|
+
env = process.env,
|
|
23
|
+
platform = process.platform,
|
|
24
|
+
} = {},
|
|
25
|
+
) {
|
|
26
|
+
if (
|
|
27
|
+
isAbsolute(command) ||
|
|
28
|
+
command.includes('/') ||
|
|
29
|
+
command.includes('\\')
|
|
30
|
+
) {
|
|
31
|
+
return executable(command);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const extensions =
|
|
35
|
+
platform === 'win32'
|
|
36
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
|
|
37
|
+
: [''];
|
|
38
|
+
const directories = (env.PATH || '').split(delimiter).filter(Boolean);
|
|
39
|
+
for (const directory of directories) {
|
|
40
|
+
for (const extension of extensions) {
|
|
41
|
+
if (await executable(join(directory, `${command}${extension}`))) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function selectCodingAgent(
|
|
50
|
+
requested,
|
|
51
|
+
available = commandAvailable,
|
|
52
|
+
) {
|
|
53
|
+
if (requested) {
|
|
54
|
+
if (!codingAgents.includes(requested)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Unsupported agent "${requested}". Choose ${codingAgents.join(', ')}.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!(await available(requested))) {
|
|
60
|
+
throw new Error(`Coding agent "${requested}" is not available.`);
|
|
61
|
+
}
|
|
62
|
+
return requested;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const agent of codingAgents) {
|
|
66
|
+
if (await available(agent)) return agent;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(
|
|
69
|
+
`No coding agent is available. Install one of: ${codingAgents.join(', ')}.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function codingAgentBinary(
|
|
74
|
+
agent,
|
|
75
|
+
{
|
|
76
|
+
codexBin,
|
|
77
|
+
env = process.env,
|
|
78
|
+
} = {},
|
|
79
|
+
) {
|
|
80
|
+
if (agent === 'codex') return codexBin || env.CODEX_BIN || agent;
|
|
81
|
+
return env[`${agent.toUpperCase()}_BIN`] || agent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseJsonText(text, agent) {
|
|
85
|
+
const trimmed = text.trim();
|
|
86
|
+
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fenced ? fenced[1] : trimmed);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`${agent} did not return valid summary JSON`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseAgentResponse(agent, stdout) {
|
|
95
|
+
if (agent === 'claude') {
|
|
96
|
+
const envelope = parseJsonText(stdout, 'Claude');
|
|
97
|
+
if (envelope?.structured_output) return envelope.structured_output;
|
|
98
|
+
if (typeof envelope?.result === 'string') {
|
|
99
|
+
return parseJsonText(envelope.result, 'Claude');
|
|
100
|
+
}
|
|
101
|
+
return envelope;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (agent === 'opencode') {
|
|
105
|
+
const parts = stdout
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.map((line) => {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(line);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
.filter((event) => event?.type === 'text' && event.part?.text)
|
|
116
|
+
.map((event) => event.part.text);
|
|
117
|
+
if (parts.length) return parseJsonText(parts.join(''), 'OpenCode');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const label = agent === 'copilot' ? 'Copilot' : 'Codex';
|
|
121
|
+
return parseJsonText(stdout, label);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function agentCommand({
|
|
125
|
+
agent,
|
|
126
|
+
binary = agent,
|
|
127
|
+
model,
|
|
128
|
+
reasoning,
|
|
129
|
+
prompt,
|
|
130
|
+
schema,
|
|
131
|
+
schemaPath,
|
|
132
|
+
inputPath,
|
|
133
|
+
workingDirectory,
|
|
134
|
+
}) {
|
|
135
|
+
if (agent === 'codex') {
|
|
136
|
+
const args = [
|
|
137
|
+
'exec',
|
|
138
|
+
'--ephemeral',
|
|
139
|
+
'--sandbox',
|
|
140
|
+
'read-only',
|
|
141
|
+
'--ignore-user-config',
|
|
142
|
+
'--color',
|
|
143
|
+
'never',
|
|
144
|
+
'--skip-git-repo-check',
|
|
145
|
+
'-C',
|
|
146
|
+
workingDirectory,
|
|
147
|
+
'--output-schema',
|
|
148
|
+
schemaPath,
|
|
149
|
+
];
|
|
150
|
+
if (model) args.push('--model', model);
|
|
151
|
+
if (reasoning) {
|
|
152
|
+
args.push(
|
|
153
|
+
'--config',
|
|
154
|
+
`model_reasoning_effort=${JSON.stringify(reasoning)}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
args.push(prompt);
|
|
158
|
+
return { command: binary, args, input: 'stdin' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (agent === 'claude') {
|
|
162
|
+
const args = [
|
|
163
|
+
'--print',
|
|
164
|
+
'--output-format',
|
|
165
|
+
'json',
|
|
166
|
+
'--json-schema',
|
|
167
|
+
JSON.stringify(schema),
|
|
168
|
+
'--tools',
|
|
169
|
+
'',
|
|
170
|
+
'--no-session-persistence',
|
|
171
|
+
];
|
|
172
|
+
if (model) args.push('--model', model);
|
|
173
|
+
args.push(prompt);
|
|
174
|
+
return { command: binary, args, input: 'stdin' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (agent === 'copilot') {
|
|
178
|
+
const schemaText = JSON.stringify(schema);
|
|
179
|
+
const args = [
|
|
180
|
+
'--silent',
|
|
181
|
+
'--no-ask-user',
|
|
182
|
+
'--no-color',
|
|
183
|
+
'--no-custom-instructions',
|
|
184
|
+
'--no-remote',
|
|
185
|
+
'--no-remote-export',
|
|
186
|
+
`--add-dir=${dirname(inputPath)}`,
|
|
187
|
+
];
|
|
188
|
+
if (model) args.push('--model', model);
|
|
189
|
+
args.push(
|
|
190
|
+
'--prompt',
|
|
191
|
+
`${prompt}\n\nRead the snapshot from @${inputPath}. Return JSON that matches this schema:\n${schemaText}`,
|
|
192
|
+
);
|
|
193
|
+
return { command: binary, args, input: 'none' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const args = [
|
|
197
|
+
'run',
|
|
198
|
+
'--pure',
|
|
199
|
+
'--format',
|
|
200
|
+
'json',
|
|
201
|
+
'--dir',
|
|
202
|
+
workingDirectory,
|
|
203
|
+
'--file',
|
|
204
|
+
inputPath,
|
|
205
|
+
];
|
|
206
|
+
if (model) args.push('--model', model);
|
|
207
|
+
if (reasoning) args.push('--variant', reasoning);
|
|
208
|
+
args.push(
|
|
209
|
+
`${prompt}\n\nThe attached JSON file is the snapshot. Return JSON that matches this schema:\n${JSON.stringify(schema)}`,
|
|
210
|
+
);
|
|
211
|
+
return { command: binary, args, input: 'none' };
|
|
212
|
+
}
|
|
@@ -13,6 +13,13 @@ import {
|
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { dirname, relative, resolve } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import {
|
|
17
|
+
agentCommand,
|
|
18
|
+
codingAgentBinary,
|
|
19
|
+
commandAvailable,
|
|
20
|
+
parseAgentResponse,
|
|
21
|
+
selectCodingAgent,
|
|
22
|
+
} from './coding-agents.mjs';
|
|
16
23
|
import { summaryPath } from './summary-path.mjs';
|
|
17
24
|
|
|
18
25
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -29,12 +36,13 @@ const valueFlags = new Set([
|
|
|
29
36
|
'--summaries',
|
|
30
37
|
'--output',
|
|
31
38
|
'--cache-dir',
|
|
39
|
+
'--agent',
|
|
32
40
|
'--codex-bin',
|
|
33
41
|
'--model',
|
|
34
42
|
'--reasoning',
|
|
35
43
|
'--batch-size',
|
|
36
44
|
]);
|
|
37
|
-
const booleanFlags = new Set(['--checkout', '--worktree']);
|
|
45
|
+
const booleanFlags = new Set(['--checkout', '--force', '--worktree']);
|
|
38
46
|
|
|
39
47
|
function fail(message) {
|
|
40
48
|
console.error(message);
|
|
@@ -76,10 +84,12 @@ Options:
|
|
|
76
84
|
--summaries FILE Agent note file
|
|
77
85
|
--output FILE Rebuilt Diffsplain JSON
|
|
78
86
|
--cache-dir PATH Bare cache for fetched Git objects
|
|
87
|
+
--agent NAME Use codex, claude, copilot, or opencode
|
|
79
88
|
--codex-bin FILE Codex CLI path (default: codex)
|
|
80
|
-
--model NAME Model passed to
|
|
81
|
-
--reasoning LEVEL
|
|
82
|
-
--batch-size COUNT Files per
|
|
89
|
+
--model NAME Model passed to the coding agent
|
|
90
|
+
--reasoning LEVEL Agent reasoning effort when supported
|
|
91
|
+
--batch-size COUNT Files per agent pass (default: 4)
|
|
92
|
+
--force Regenerate all notes instead of using cached notes`);
|
|
83
93
|
process.exit(0);
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -88,7 +98,18 @@ const outputPath = resolve(
|
|
|
88
98
|
callerDirectory,
|
|
89
99
|
option('--output') || resolve(root, '.cache/diff-data.json'),
|
|
90
100
|
);
|
|
91
|
-
const codexBin = option('--codex-bin') || process.env.CODEX_BIN
|
|
101
|
+
const codexBin = option('--codex-bin') || process.env.CODEX_BIN;
|
|
102
|
+
let selectedAgent;
|
|
103
|
+
try {
|
|
104
|
+
selectedAgent = await selectCodingAgent(
|
|
105
|
+
option('--agent'),
|
|
106
|
+
(agent) =>
|
|
107
|
+
commandAvailable(codingAgentBinary(agent, { codexBin })),
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
fail(error.message);
|
|
111
|
+
}
|
|
112
|
+
const agentBinary = codingAgentBinary(selectedAgent, { codexBin });
|
|
92
113
|
const model = option('--model');
|
|
93
114
|
const reasoning = option('--reasoning');
|
|
94
115
|
const batchSizeValue = option('--batch-size') || '4';
|
|
@@ -113,6 +134,7 @@ const pr = option('--pr');
|
|
|
113
134
|
const branch = option('--branch');
|
|
114
135
|
const checkout = rawArgs.includes('--checkout');
|
|
115
136
|
const remote = option('--remote') || 'origin';
|
|
137
|
+
const force = rawArgs.includes('--force');
|
|
116
138
|
|
|
117
139
|
if (range && (base || head)) {
|
|
118
140
|
fail('--range cannot be used with --base or --head');
|
|
@@ -575,6 +597,7 @@ try {
|
|
|
575
597
|
const changedPaths = [];
|
|
576
598
|
for (const path of paths) {
|
|
577
599
|
if (
|
|
600
|
+
!force &&
|
|
578
601
|
previousFingerprints[path] === fileFingerprints[path] &&
|
|
579
602
|
completeFileNote(previousFiles[path])
|
|
580
603
|
) {
|
|
@@ -584,6 +607,7 @@ try {
|
|
|
584
607
|
}
|
|
585
608
|
}
|
|
586
609
|
const changeNeedsRefresh =
|
|
610
|
+
force ||
|
|
587
611
|
previousSummaries.meta?.reviewFingerprint !==
|
|
588
612
|
rawSnapshot.notes.reviewFingerprint ||
|
|
589
613
|
!completeChangeNote(previousSummaries.change);
|
|
@@ -626,40 +650,36 @@ try {
|
|
|
626
650
|
`${JSON.stringify(outputSchema(batchPaths), null, 2)}\n`,
|
|
627
651
|
);
|
|
628
652
|
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
653
|
+
const input = batchInput(
|
|
654
|
+
snapshot,
|
|
655
|
+
rawSnapshot,
|
|
656
|
+
batchPaths,
|
|
657
|
+
workingSummaries.files,
|
|
658
|
+
);
|
|
659
|
+
const inputPath = resolve(
|
|
660
|
+
temporaryDirectory,
|
|
661
|
+
`summary-input-${index + 1}.json`,
|
|
662
|
+
);
|
|
663
|
+
writeFileSync(inputPath, input);
|
|
664
|
+
const invocation = agentCommand({
|
|
665
|
+
agent: selectedAgent,
|
|
666
|
+
binary: agentBinary,
|
|
667
|
+
model,
|
|
668
|
+
reasoning,
|
|
669
|
+
prompt: promptFor(batchPaths),
|
|
670
|
+
schema: outputSchema(batchPaths),
|
|
640
671
|
schemaPath,
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
codexArgs.push(
|
|
645
|
-
'--config',
|
|
646
|
-
`model_reasoning_effort=${JSON.stringify(reasoning)}`,
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
codexArgs.push(promptFor(batchPaths));
|
|
672
|
+
inputPath,
|
|
673
|
+
workingDirectory: root,
|
|
674
|
+
});
|
|
650
675
|
|
|
651
676
|
console.error(
|
|
652
|
-
`Asking
|
|
677
|
+
`Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
|
|
653
678
|
);
|
|
654
|
-
const result = spawnSync(
|
|
679
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
655
680
|
cwd: root,
|
|
656
681
|
encoding: 'utf8',
|
|
657
|
-
input:
|
|
658
|
-
snapshot,
|
|
659
|
-
rawSnapshot,
|
|
660
|
-
batchPaths,
|
|
661
|
-
workingSummaries.files,
|
|
662
|
-
),
|
|
682
|
+
input: invocation.input === 'stdin' ? input : undefined,
|
|
663
683
|
maxBuffer: 10 * 1024 * 1024,
|
|
664
684
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
665
685
|
});
|
|
@@ -667,24 +687,21 @@ try {
|
|
|
667
687
|
if (result.status !== 0) {
|
|
668
688
|
const detail = result.stderr
|
|
669
689
|
.split('\n')
|
|
670
|
-
.
|
|
671
|
-
(
|
|
672
|
-
|
|
673
|
-
|
|
690
|
+
.map((line) =>
|
|
691
|
+
line.replace(
|
|
692
|
+
/\u001b\[[0-?]*[ -/]*[@-~]/g,
|
|
693
|
+
'',
|
|
694
|
+
),
|
|
674
695
|
)
|
|
675
|
-
.
|
|
696
|
+
.filter((line) => line.trim() && line.length < 600)
|
|
697
|
+
.slice(-8)
|
|
676
698
|
.join('\n');
|
|
677
699
|
throw new Error(
|
|
678
|
-
|
|
700
|
+
`${selectedAgent} exited with status ${result.status}${detail ? `\n${detail}` : ''}`,
|
|
679
701
|
);
|
|
680
702
|
}
|
|
681
703
|
|
|
682
|
-
|
|
683
|
-
try {
|
|
684
|
-
response = JSON.parse(result.stdout);
|
|
685
|
-
} catch {
|
|
686
|
-
throw new Error('Codex did not return valid summary JSON');
|
|
687
|
-
}
|
|
704
|
+
const response = parseAgentResponse(selectedAgent, result.stdout);
|
|
688
705
|
const normalized = normalizeResponse(response, batchPaths);
|
|
689
706
|
workingSummaries = {
|
|
690
707
|
change: normalized.change,
|
package/scripts/present.mjs
CHANGED
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
13
|
import { createInterface } from 'node:readline';
|
|
8
14
|
import { fileURLToPath } from 'node:url';
|
|
9
15
|
import { helpText, parseCliArgs } from './cli-args.mjs';
|
|
16
|
+
import {
|
|
17
|
+
codingAgentBinary,
|
|
18
|
+
commandAvailable,
|
|
19
|
+
selectCodingAgent,
|
|
20
|
+
} from './coding-agents.mjs';
|
|
10
21
|
|
|
11
22
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
23
|
const callerDirectory = process.cwd();
|
|
@@ -23,16 +34,46 @@ if (cli.help) {
|
|
|
23
34
|
console.log(helpText);
|
|
24
35
|
process.exit(0);
|
|
25
36
|
}
|
|
37
|
+
if (cli.version) {
|
|
38
|
+
const packageJson = JSON.parse(
|
|
39
|
+
readFileSync(resolve(root, 'package.json'), 'utf8'),
|
|
40
|
+
);
|
|
41
|
+
console.log(`diffsplain ${packageJson.version}`);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
const { agentEnabled, port } = cli;
|
|
28
46
|
const feedArgs = [...cli.feedArgs];
|
|
29
47
|
const agentArgs = [...cli.agentArgs];
|
|
48
|
+
if (agentEnabled) {
|
|
49
|
+
try {
|
|
50
|
+
const selectedAgent = await selectCodingAgent(
|
|
51
|
+
cli.agent,
|
|
52
|
+
(agent) =>
|
|
53
|
+
commandAvailable(
|
|
54
|
+
codingAgentBinary(agent, { codexBin: cli.codexBin }),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
agentArgs.push('--agent', selectedAgent);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(error.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
30
63
|
const outputIndex = feedArgs.indexOf('--output');
|
|
64
|
+
let runtimeDirectory;
|
|
31
65
|
|
|
32
66
|
if (outputIndex === -1) {
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
runtimeDirectory = mkdtempSync(join(tmpdir(), 'diffsplain-live-'));
|
|
68
|
+
const liveOutput = resolve(runtimeDirectory, 'diff-data.json');
|
|
69
|
+
feedArgs.push('--output', liveOutput);
|
|
70
|
+
agentArgs.push('--output', liveOutput);
|
|
35
71
|
}
|
|
72
|
+
process.on('exit', () => {
|
|
73
|
+
if (runtimeDirectory) {
|
|
74
|
+
rmSync(runtimeDirectory, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
36
77
|
if (!feedArgs.includes('--watch')) feedArgs.push('--watch');
|
|
37
78
|
const outputPath = resolve(
|
|
38
79
|
callerDirectory,
|
|
@@ -71,14 +112,55 @@ const site = spawn(
|
|
|
71
112
|
outputPath,
|
|
72
113
|
'--port',
|
|
73
114
|
String(port),
|
|
115
|
+
...(!cli.portWasPassed ? ['--increment-port'] : []),
|
|
74
116
|
],
|
|
75
|
-
{ cwd: root, stdio: 'inherit' },
|
|
117
|
+
{ cwd: root, stdio: ['ignore', 'pipe', 'inherit'] },
|
|
76
118
|
);
|
|
77
119
|
let closing = false;
|
|
78
120
|
let agent;
|
|
79
121
|
let agentTimer;
|
|
80
122
|
let agentFingerprint;
|
|
81
123
|
let queuedFingerprint;
|
|
124
|
+
let browserOpened = false;
|
|
125
|
+
|
|
126
|
+
function openBrowser(url) {
|
|
127
|
+
let command;
|
|
128
|
+
let args;
|
|
129
|
+
if (process.env.BROWSER) {
|
|
130
|
+
command = process.env.BROWSER;
|
|
131
|
+
args = [url];
|
|
132
|
+
} else if (process.platform === 'darwin') {
|
|
133
|
+
command = 'open';
|
|
134
|
+
args = [url];
|
|
135
|
+
} else if (process.platform === 'win32') {
|
|
136
|
+
command = 'cmd.exe';
|
|
137
|
+
args = ['/d', '/s', '/c', 'start', '', url];
|
|
138
|
+
} else {
|
|
139
|
+
command = 'xdg-open';
|
|
140
|
+
args = [url];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const opener = spawn(command, args, {
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: 'ignore',
|
|
146
|
+
});
|
|
147
|
+
opener.once('error', (error) => {
|
|
148
|
+
console.error(`Could not open the browser: ${error.message}`);
|
|
149
|
+
});
|
|
150
|
+
opener.unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (site.stdout) {
|
|
154
|
+
const siteLines = createInterface({ input: site.stdout });
|
|
155
|
+
siteLines.on('line', (line) => {
|
|
156
|
+
console.log(line);
|
|
157
|
+
const match = line.match(/^Diffsplain: (http:\/\/\S+)$/);
|
|
158
|
+
if (!browserOpened && match) {
|
|
159
|
+
browserOpened = true;
|
|
160
|
+
openBrowser(match[1]);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
82
164
|
|
|
83
165
|
function snapshotState() {
|
|
84
166
|
try {
|
|
@@ -170,6 +252,7 @@ function scheduleAgent(fingerprint) {
|
|
|
170
252
|
const state = snapshotState();
|
|
171
253
|
const selectedFingerprint = fingerprint || state?.fingerprint;
|
|
172
254
|
if (
|
|
255
|
+
!cli.forceSummaryRegeneration &&
|
|
173
256
|
!agentFingerprint &&
|
|
174
257
|
state?.hasCurrentAgentNotes &&
|
|
175
258
|
selectedFingerprint === state.fingerprint
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
9
|
+
|
|
10
|
+
function runNpm(args) {
|
|
11
|
+
const result = spawnSync(npm, args, {
|
|
12
|
+
cwd: root,
|
|
13
|
+
stdio: 'inherit',
|
|
14
|
+
});
|
|
15
|
+
if (result.error) throw result.error;
|
|
16
|
+
return result.status ?? 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runRelease(versionArgs, run = runNpm) {
|
|
20
|
+
if (!versionArgs[0] || versionArgs[0].startsWith('-')) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Usage: npm run release -- <version> [npm version options]',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const steps = [
|
|
27
|
+
['version', ...versionArgs],
|
|
28
|
+
['publish', '--access', 'public'],
|
|
29
|
+
];
|
|
30
|
+
for (const args of steps) {
|
|
31
|
+
const status = run(args);
|
|
32
|
+
if (status !== 0) return status;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
38
|
+
try {
|
|
39
|
+
process.exitCode = runRelease(process.argv.slice(2));
|
|
40
|
+
if (process.exitCode === 0) {
|
|
41
|
+
console.log(
|
|
42
|
+
'Published. Push the version commit and tag with: git push origin main --follow-tags',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
47
|
+
process.exitCode = 2;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/scripts/serve-built.mjs
CHANGED
|
@@ -21,10 +21,12 @@ function option(name, fallback) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const output = resolve(option('--output', resolve(root, '.cache/diff-data.json')));
|
|
24
|
-
const portValue = option('--port', '
|
|
24
|
+
const portValue = option('--port', '2299');
|
|
25
25
|
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
|
|
26
26
|
throw new Error('--port must be a number from 0 to 65535');
|
|
27
27
|
}
|
|
28
|
+
const incrementPort =
|
|
29
|
+
rawArgs.includes('--increment-port') || !rawArgs.includes('--port');
|
|
28
30
|
|
|
29
31
|
const contentTypes = {
|
|
30
32
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -115,13 +117,34 @@ const server = createServer(async (request, response) => {
|
|
|
115
117
|
}
|
|
116
118
|
});
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
let selectedPort = Number(portValue);
|
|
121
|
+
|
|
122
|
+
function listen() {
|
|
123
|
+
server.listen(selectedPort, '127.0.0.1', () => {
|
|
124
|
+
const address = server.address();
|
|
125
|
+
const readyPort =
|
|
126
|
+
address && typeof address === 'object' ? address.port : selectedPort;
|
|
127
|
+
console.log(`Diffsplain: http://127.0.0.1:${readyPort}`);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
server.on('error', (error) => {
|
|
132
|
+
if (
|
|
133
|
+
error.code === 'EADDRINUSE' &&
|
|
134
|
+
incrementPort &&
|
|
135
|
+
selectedPort > 0 &&
|
|
136
|
+
selectedPort < 65_535
|
|
137
|
+
) {
|
|
138
|
+
selectedPort += 1;
|
|
139
|
+
listen();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
console.error(`Could not start Diffsplain: ${error.message}`);
|
|
143
|
+
process.exitCode = 1;
|
|
123
144
|
});
|
|
124
145
|
|
|
146
|
+
listen();
|
|
147
|
+
|
|
125
148
|
let closing = false;
|
|
126
149
|
function close() {
|
|
127
150
|
if (closing) return;
|