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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Diffsplain
2
2
 
3
- Review a Git diff one file at a time, with a short Codex note beside each
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 [http://localhost:3000](http://localhost:3000) and compares
15
- the checkout with its default branch. You need Node.js 22.13 or newer and a
16
- signed-in Codex CLI. Pull requests also need a signed-in GitHub CLI.
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 [codex]`, `--no-agent` | Enable Codex notes, or show a plain diff. |
37
- | `--model NAME` | Choose the Codex model used for notes. |
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 Codex pass. The default is `4`. |
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 the local port. The default is `3000`. |
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.0",
4
- "description": "Review Git diffs one file at a time with Codex notes beside each patch.",
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/diff-presenter.git"
18
+ "url": "git+https://github.com/itsjling/diffsplain.git"
16
19
  },
17
- "homepage": "https://github.com/itsjling/diff-presenter#readme",
20
+ "homepage": "https://github.com/itsjling/diffsplain#readme",
18
21
  "bugs": {
19
- "url": "https://github.com/itsjling/diff-presenter/issues"
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 3000",
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",
@@ -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 [codex] Coding agent (default: codex)
51
+ --agent NAME Use codex, claude, copilot, or opencode
49
52
  --no-agent Do not write agent notes
50
- --model NAME Codex model for agent notes
51
- --reasoning LEVEL Codex reasoning effort for agent notes
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: 3000)
55
- --help Show this 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 codex`;
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 = 'codex';
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 !== 'codex') {
177
- fail(`Unsupported agent "${agent}". Only "codex" is supported for now.`);
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') || '3000';
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 codex exec
81
- --reasoning LEVEL Reasoning effort passed to codex exec
82
- --batch-size COUNT Files per Codex pass (default: 4)`);
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 || 'codex';
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 codexArgs = [
630
- 'exec',
631
- '--ephemeral',
632
- '--sandbox',
633
- 'read-only',
634
- '--ignore-user-config',
635
- '--color',
636
- 'never',
637
- '-C',
638
- root,
639
- '--output-schema',
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
- if (model) codexArgs.push('--model', model);
643
- if (reasoning) {
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 Codex for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
677
+ `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
653
678
  );
654
- const result = spawnSync(codexBin, codexArgs, {
679
+ const result = spawnSync(invocation.command, invocation.args, {
655
680
  cwd: root,
656
681
  encoding: 'utf8',
657
- input: batchInput(
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
- .filter(
671
- (line) =>
672
- line.length < 600 &&
673
- /\b(error|failed|denied|unauthorized|forbidden)\b/i.test(line),
690
+ .map((line) =>
691
+ line.replace(
692
+ /\u001b\[[0-?]*[ -/]*[@-~]/g,
693
+ '',
694
+ ),
674
695
  )
675
- .slice(-5)
696
+ .filter((line) => line.trim() && line.length < 600)
697
+ .slice(-8)
676
698
  .join('\n');
677
699
  throw new Error(
678
- `Codex exited with status ${result.status}${detail ? `\n${detail}` : ''}`,
700
+ `${selectedAgent} exited with status ${result.status}${detail ? `\n${detail}` : ''}`,
679
701
  );
680
702
  }
681
703
 
682
- let response;
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,
@@ -2,11 +2,22 @@
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { dirname, resolve } from 'node:path';
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
- feedArgs.push('--output', resolve(root, '.cache/diff-data.json'));
34
- agentArgs.push('--output', resolve(root, '.cache/diff-data.json'));
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
+ }
@@ -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', '3000');
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
- server.listen(Number(portValue), '127.0.0.1', () => {
119
- const address = server.address();
120
- const selectedPort =
121
- address && typeof address === 'object' ? address.port : Number(portValue);
122
- console.log(`Diffsplain: http://127.0.0.1:${selectedPort}`);
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;