kodevu 0.1.53 → 0.1.55

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,5 +1,7 @@
1
1
  # Kodevu
2
2
 
3
+ > The name **Kodevu** is a phonetic play on "code review".
4
+
3
5
  A Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files.
4
6
 
5
7
  ## Pure & Zero Config
@@ -32,6 +34,7 @@ npx kodevu [target] [options]
32
34
  - `--reviewer, -r`: `codex`, `gemini`, `copilot`, `openai`, or `auto` (default: `auto`).
33
35
  - `--rev, -v`: A specific revision or commit hash to review.
34
36
  - `--last, -n`: Number of latest revisions to review (default: 1). Use negative values (e.g., `-3`) to review only the 3rd commit from the top.
37
+ - `--uncommitted, -u`: Review current uncommitted changes in the target working tree.
35
38
  - `--lang, -l`: Output language (e.g., `zh`, `en`, `auto`).
36
39
  - `--prompt, -p`: Additional instructions for the reviewer. Use `@file.txt` to read from a file.
37
40
  - `--output, -o`: Report output directory (default: `~/.kodevu`).
@@ -47,6 +50,9 @@ npx kodevu [target] [options]
47
50
  > [!IMPORTANT]
48
51
  > `--rev` and `--last` are mutually exclusive. Specifying both will result in an error.
49
52
 
53
+ > [!IMPORTANT]
54
+ > `--uncommitted` is mutually exclusive with `--rev` and `--last`.
55
+
50
56
  ### Environment Variables
51
57
 
52
58
  You can set these in your shell to change default behavior without typing flags every time:
@@ -81,6 +87,11 @@ Review a **specific commit** hash:
81
87
  npx kodevu . --rev abc1234
82
88
  ```
83
89
 
90
+ Review **current uncommitted changes** (Git/SVN working copy):
91
+ ```bash
92
+ npx kodevu . --uncommitted
93
+ ```
94
+
84
95
  ### Options & Formatting
85
96
 
86
97
  Review using **custom instructions** from a file:
@@ -118,13 +129,6 @@ npx kodevu . \
118
129
  --openai-model gpt-5-mini
119
130
  ```
120
131
 
121
- ## How it Works
122
-
123
- - **Git Targets**: `target` must be a local repository or subdirectory.
124
- - **SVN Targets**: `target` can be a working copy path or repository URL.
125
- - **Reviewer "auto"**: Probes `codex`, `gemini`, and `copilot` in your `PATH` and selects one.
126
- - **Reviewer "openai"**: Calls the OpenAI Chat Completions API directly. `auto` does not select `openai`, so API-based use stays explicit.
127
- - **Contextual Review**: For local repositories, the reviewer can inspect related files beyond the diff to provide deeper insights.
128
132
 
129
133
  ## License
130
134
 
package/SKILL.md CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: kodevu
3
- description: A tool to fetch Git commits or SVN revisions, send the diff to a supported AI reviewer CLI, and write configurable review reports.
3
+ description: A tool to fetch Git/SVN diffs, send them to an AI reviewer, and generate configurable code review reports.
4
4
  ---
5
5
 
6
6
  # Kodevu Skill
7
7
 
8
- Kodevu is a Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files.
8
+ Kodevu is a Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files. It is designed to be **stateless** and requires **no configuration files**.
9
9
 
10
10
  ## Usage
11
11
 
12
- Use `npx kodevu` to review a codebase. It is designed to be stateless and requires no configuration files.
12
+ Use `npx kodevu` to review a codebase.
13
13
 
14
14
  ### Reviewing the latest commit
15
15
 
@@ -29,13 +29,19 @@ npx kodevu . --rev <commit-hash>
29
29
  npx kodevu . --last 3
30
30
  ```
31
31
 
32
+ ### Reviewing uncommitted changes
33
+
34
+ ```bash
35
+ npx kodevu . --uncommitted
36
+ ```
37
+
32
38
  ### Supported Reviewers
33
39
 
34
- `kodevu` supports several AI reviewer CLIs: `auto`, `openai`, `gemini`, `codex`, `copilot`. The default is `auto`. Use the `--reviewer` option to override.
40
+ `kodevu` supports several AI reviewer backends: `auto`, `openai`, `gemini`, `codex`, `copilot`. The default is `auto`, which probes available CLI tools in your `PATH`.
35
41
 
36
42
  Example using OpenAI:
37
43
  ```bash
38
- npx kodevu . --reviewer openai --openai-api-key <YOUR_API_KEY> --openai-model gpt-4o
44
+ npx kodevu . --reviewer openai --openai-api-key <YOUR_API_KEY> --openai-model gpt-5-mini
39
45
  ```
40
46
 
41
47
  ### Generating JSON Reports
@@ -45,19 +51,31 @@ By default, review reports are generated as Markdown files in `~/.kodevu/`. You
45
51
  npx kodevu . --format json --output ./reports
46
52
  ```
47
53
 
48
- ### Formatting the Prompt
54
+ ### Custom Prompts
49
55
 
50
- You can provide clear instructions to the reviewer using `--prompt`:
56
+ You can provide additional instructions to the reviewer using `--prompt`:
51
57
  ```bash
52
58
  npx kodevu . --prompt "Focus on security issues and suggest optimizations."
53
59
  ```
54
60
  Or from a file: `--prompt @my-rules.txt`
55
61
 
62
+ ### Environment Variables
63
+
64
+ All options can also be set via environment variables to avoid repetitive flags:
65
+
66
+ - `KODEVU_REVIEWER` – Default reviewer.
67
+ - `KODEVU_LANG` – Default output language.
68
+ - `KODEVU_OUTPUT_DIR` – Default output directory.
69
+ - `KODEVU_PROMPT` – Default prompt instructions.
70
+ - `KODEVU_OPENAI_API_KEY` – API key for `openai`.
71
+ - `KODEVU_OPENAI_BASE_URL` – Base URL for `openai`.
72
+ - `KODEVU_OPENAI_MODEL` – Model for `openai`.
73
+
56
74
  ## Working with Target Repositories
57
75
 
58
- - `target`: Repository path (Git) or SVN URL/Working copy (default: `.`).
76
+ - **Git**: `target` must be a local repository or subdirectory.
77
+ - **SVN**: `target` can be a working copy path or repository URL.
59
78
 
60
- For example, to run on a specific path, you can use:
61
79
  ```bash
62
80
  npx kodevu /path/to/project --last 1
63
81
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
package/src/config.js CHANGED
@@ -23,6 +23,7 @@ const defaultConfig = {
23
23
  outputFormats: ["markdown"],
24
24
  rev: "",
25
25
  last: 0,
26
+ uncommitted: false,
26
27
  openaiApiKey: "",
27
28
  openaiBaseUrl: "https://api.openai.com/v1",
28
29
  openaiModel: "gpt-5-mini",
@@ -124,6 +125,7 @@ export function parseCliArgs(argv) {
124
125
  prompt: "",
125
126
  rev: "",
126
127
  last: "",
128
+ uncommitted: false,
127
129
  outputDir: "",
128
130
  outputFormats: "",
129
131
  openaiApiKey: "",
@@ -190,6 +192,11 @@ export function parseCliArgs(argv) {
190
192
  continue;
191
193
  }
192
194
 
195
+ if (value === "--uncommitted" || value === "-u") {
196
+ args.uncommitted = true;
197
+ continue;
198
+ }
199
+
193
200
  if (value === "--output" || value === "-o") {
194
201
  if (!hasNextValue) throw new Error(`Missing value for ${value}`);
195
202
  args.outputDir = nextValue;
@@ -268,6 +275,7 @@ export async function resolveConfig(cliArgs = {}) {
268
275
  "lang",
269
276
  "rev",
270
277
  "last",
278
+ "uncommitted",
271
279
  "outputDir",
272
280
  "outputFormats",
273
281
  "openaiApiKey",
@@ -285,6 +293,10 @@ export async function resolveConfig(cliArgs = {}) {
285
293
  throw new Error("Parameters --rev and --last are mutually exclusive. Please specify only one.");
286
294
  }
287
295
 
296
+ if (cliArgs.uncommitted && (cliArgs.rev || cliArgs.last)) {
297
+ throw new Error("Parameter --uncommitted is mutually exclusive with --rev and --last.");
298
+ }
299
+
288
300
  if (!config.target) {
289
301
  config.target = process.cwd();
290
302
  }
@@ -321,6 +333,7 @@ export async function resolveConfig(cliArgs = {}) {
321
333
  config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
322
334
  config.commandTimeoutMs = Number(config.commandTimeoutMs);
323
335
  config.last = Number(config.last);
336
+ config.uncommitted = Boolean(config.uncommitted);
324
337
  config.outputFormats = normalizeOutputFormats(config.outputFormats);
325
338
  config.openaiApiKey = String(config.openaiApiKey || "").trim();
326
339
  config.openaiBaseUrl = String(config.openaiBaseUrl || defaultConfig.openaiBaseUrl).trim().replace(/\/+$/, "");
@@ -328,7 +341,7 @@ export async function resolveConfig(cliArgs = {}) {
328
341
  config.openaiOrganization = String(config.openaiOrganization || "").trim();
329
342
  config.openaiProject = String(config.openaiProject || "").trim();
330
343
 
331
- if (!config.rev && (isNaN(config.last) || config.last === 0)) {
344
+ if (!config.uncommitted && !config.rev && (isNaN(config.last) || config.last === 0)) {
332
345
  config.last = 1;
333
346
  }
334
347
 
@@ -352,6 +365,7 @@ Options:
352
365
  --lang, -l Output language (e.g. zh, en, auto)
353
366
  --rev, -v Review specific revision(s), hashes, branches or ranges (comma-separated)
354
367
  --last, -n Review the latest N revisions; use negative (-N) to review only the Nth-from-last revision (default: 1)
368
+ --uncommitted, -u Review current uncommitted changes (mutually exclusive with --rev and --last)
355
369
  --output, -o Output directory (default: ~/.kodevu)
356
370
  --format, -f Output formats (markdown, json, comma-separated)
357
371
  --openai-api-key API key used when reviewer=openai
package/src/git-client.js CHANGED
@@ -181,6 +181,49 @@ function parseNameStatus(stdout) {
181
181
  return changedPaths;
182
182
  }
183
183
 
184
+ function parseNameStatusLines(stdout) {
185
+ return stdout
186
+ .split(/\r?\n/)
187
+ .map((line) => line.trim())
188
+ .filter(Boolean)
189
+ .map((line) => line.split("\t"))
190
+ .map((parts) => {
191
+ const status = (parts[0] || "M").trim();
192
+ const action = status[0] || "M";
193
+
194
+ if ((action === "R" || action === "C") && parts.length >= 3) {
195
+ return {
196
+ action,
197
+ relativePath: parts[2],
198
+ previousPath: parts[1] || null
199
+ };
200
+ }
201
+
202
+ return {
203
+ action,
204
+ relativePath: parts[1] || "",
205
+ previousPath: null
206
+ };
207
+ })
208
+ .filter((item) => item.relativePath);
209
+ }
210
+
211
+ function mergeChangedPaths(...groups) {
212
+ const merged = [];
213
+ const seen = new Set();
214
+
215
+ for (const group of groups) {
216
+ for (const item of group) {
217
+ const key = `${item.action}|${item.relativePath}|${item.previousPath || ""}`;
218
+ if (seen.has(key)) continue;
219
+ seen.add(key);
220
+ merged.push(item);
221
+ }
222
+ }
223
+
224
+ return merged;
225
+ }
226
+
184
227
  export async function getCommitDetails(config, targetInfo, commitHash) {
185
228
  const metaResult = await runGit(
186
229
  config,
@@ -214,3 +257,63 @@ export async function getCommitDetails(config, targetInfo, commitHash) {
214
257
  changedPaths: parseNameStatus(changedFilesResult.stdout)
215
258
  };
216
259
  }
260
+
261
+ export async function getUncommittedDiff(config, targetInfo) {
262
+ const unstaged = await runGit(
263
+ config,
264
+ [
265
+ "diff",
266
+ "--find-renames",
267
+ "--find-copies",
268
+ "--no-ext-diff",
269
+ ...buildPathArgs(targetInfo)
270
+ ],
271
+ { cwd: targetInfo.repoRootPath, trim: false }
272
+ );
273
+ const staged = await runGit(
274
+ config,
275
+ [
276
+ "diff",
277
+ "--cached",
278
+ "--find-renames",
279
+ "--find-copies",
280
+ "--no-ext-diff",
281
+ ...buildPathArgs(targetInfo)
282
+ ],
283
+ { cwd: targetInfo.repoRootPath, trim: false }
284
+ );
285
+
286
+ const sections = [];
287
+ if (unstaged.stdout.trim()) {
288
+ sections.push("# Unstaged changes", unstaged.stdout.trimEnd());
289
+ }
290
+ if (staged.stdout.trim()) {
291
+ sections.push("# Staged changes", staged.stdout.trimEnd());
292
+ }
293
+
294
+ return sections.join("\n\n");
295
+ }
296
+
297
+ export async function getUncommittedDetails(config, targetInfo) {
298
+ const unstagedFiles = await runGit(
299
+ config,
300
+ ["diff", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
301
+ { cwd: targetInfo.repoRootPath, trim: false }
302
+ );
303
+ const stagedFiles = await runGit(
304
+ config,
305
+ ["diff", "--cached", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
306
+ { cwd: targetInfo.repoRootPath, trim: false }
307
+ );
308
+
309
+ const unstagedChanged = parseNameStatusLines(unstagedFiles.stdout);
310
+ const stagedChanged = parseNameStatusLines(stagedFiles.stdout);
311
+
312
+ return {
313
+ commitHash: "UNCOMMITTED",
314
+ author: "working-tree",
315
+ date: new Date().toISOString(),
316
+ message: "Uncommitted changes (staged + unstaged).",
317
+ changedPaths: mergeChangedPaths(unstagedChanged, stagedChanged)
318
+ };
319
+ }
@@ -155,7 +155,9 @@ export async function runReviewCycle(config) {
155
155
 
156
156
  let changeIdsToReview = [];
157
157
 
158
- if (config.rev) {
158
+ if (config.uncommitted) {
159
+ changeIdsToReview = ["UNCOMMITTED"];
160
+ } else if (config.rev) {
159
161
  changeIdsToReview = await backend.resolveChangeIds(config, targetInfo, config.rev);
160
162
  } else if (config.last < 0) {
161
163
  const candidates = await backend.getLatestChangeIds(config, targetInfo, Math.abs(config.last));
@@ -169,7 +171,12 @@ export async function runReviewCycle(config) {
169
171
  return;
170
172
  }
171
173
 
172
- logger.info(`Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`);
174
+ const isUncommittedBatch =
175
+ changeIdsToReview.length === 1 && changeIdsToReview[0] === "UNCOMMITTED";
176
+ const batchSummary = isUncommittedBatch
177
+ ? `Reviewing ${backend.displayName} uncommitted changes`
178
+ : `Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`;
179
+ logger.info(batchSummary);
173
180
  const progress = createProgressReporter(`${backend.displayName} ${backend.changeName} batch`);
174
181
  progress.update(0, `0/${changeIdsToReview.length} completed`);
175
182
 
package/src/svn-client.js CHANGED
@@ -128,6 +128,24 @@ function toRelativePath(targetRepoPath, repoPath) {
128
128
  return repoPath.slice(targetRepoPath.length).replace(/^\/+/, "");
129
129
  }
130
130
 
131
+ function parseSvnStatus(statusText) {
132
+ return statusText
133
+ .split(/\r?\n/)
134
+ .map((line) => line.replace(/\r$/, ""))
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const action = (line[0] || " ").trim();
138
+ const relativePath = line.length > 8 ? line.slice(8).trim() : "";
139
+ return {
140
+ action,
141
+ relativePath,
142
+ previousPath: null
143
+ };
144
+ })
145
+ .filter((item) => item.relativePath)
146
+ .filter((item) => ["A", "D", "M", "R"].includes(item.action));
147
+ }
148
+
131
149
  export async function getRevisionDetails(config, targetInfo, revision) {
132
150
  const result = await runCommand(
133
151
  SVN_COMMAND,
@@ -165,3 +183,37 @@ export async function getRevisionDetails(config, targetInfo, revision) {
165
183
  changedPaths
166
184
  };
167
185
  }
186
+
187
+ export async function getUncommittedDiff(config, targetInfo) {
188
+ if (!targetInfo.workingCopyPath) {
189
+ throw new Error("SVN --uncommitted requires a working copy path target.");
190
+ }
191
+
192
+ const result = await runCommand(
193
+ SVN_COMMAND,
194
+ ["diff", "--git", "--internal-diff", "--ignore-properties", config.target],
195
+ { encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
196
+ );
197
+
198
+ return result.stdout;
199
+ }
200
+
201
+ export async function getUncommittedDetails(config, targetInfo) {
202
+ if (!targetInfo.workingCopyPath) {
203
+ throw new Error("SVN --uncommitted requires a working copy path target.");
204
+ }
205
+
206
+ const statusResult = await runCommand(
207
+ SVN_COMMAND,
208
+ ["status", config.target],
209
+ { encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
210
+ );
211
+
212
+ return {
213
+ revision: "UNCOMMITTED",
214
+ author: "working-copy",
215
+ date: new Date().toISOString(),
216
+ message: "Uncommitted changes in working copy.",
217
+ changedPaths: parseSvnStatus(statusResult.stdout)
218
+ };
219
+ }
package/src/utils.js CHANGED
@@ -50,9 +50,10 @@ export function formatDate(dateInput) {
50
50
  const hours = pad(d.getHours());
51
51
  const minutes = pad(d.getMinutes());
52
52
  const seconds = pad(d.getSeconds());
53
+ const milliseconds = String(d.getMilliseconds()).padStart(3, "0");
53
54
  const offset = `${sign}${pad(offsetHours)}:${pad(offsetMins)}`;
54
55
 
55
- return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${offset}`;
56
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds} ${offset}`;
56
57
  }
57
58
  export function getTimestampPrefix() {
58
59
  const now = new Date();
package/src/vcs-client.js CHANGED
@@ -13,9 +13,13 @@ function createSvnBackend() {
13
13
  displayName: "SVN",
14
14
  changeName: "revision",
15
15
  formatChangeId(revision) {
16
+ if (revision === "UNCOMMITTED") return "uncommitted";
16
17
  return `r${revision}`;
17
18
  },
18
19
  getReportFileName(revision) {
20
+ if (revision === "UNCOMMITTED") {
21
+ return `${getTimestampPrefix()}-svn-uncommitted.md`;
22
+ }
19
23
  return `${getTimestampPrefix()}-svn-r${revision}.md`;
20
24
  },
21
25
 
@@ -33,9 +37,25 @@ function createSvnBackend() {
33
37
  return await svnClient.getLatestRevisionIds(config, targetInfo, limit);
34
38
  },
35
39
  async getChangeDiff(config, targetInfo, revision) {
40
+ if (revision === "UNCOMMITTED") {
41
+ return await svnClient.getUncommittedDiff(config, targetInfo);
42
+ }
36
43
  return await svnClient.getRevisionDiff(config, revision);
37
44
  },
38
45
  async getChangeDetails(config, targetInfo, revision) {
46
+ if (revision === "UNCOMMITTED") {
47
+ const details = await svnClient.getUncommittedDetails(config, targetInfo);
48
+
49
+ return {
50
+ id: "UNCOMMITTED",
51
+ displayId: "uncommitted",
52
+ author: details.author,
53
+ date: details.date,
54
+ message: details.message,
55
+ changedPaths: details.changedPaths
56
+ };
57
+ }
58
+
39
59
  const details = await svnClient.getRevisionDetails(config, targetInfo, revision);
40
60
 
41
61
  return {
@@ -56,9 +76,13 @@ function createGitBackend() {
56
76
  displayName: "Git",
57
77
  changeName: "commit",
58
78
  formatChangeId(commitHash) {
79
+ if (commitHash === "UNCOMMITTED") return "uncommitted";
59
80
  return commitHash.slice(0, 12);
60
81
  },
61
82
  getReportFileName(commitHash) {
83
+ if (commitHash === "UNCOMMITTED") {
84
+ return `${getTimestampPrefix()}-git-uncommitted.md`;
85
+ }
62
86
  return `${getTimestampPrefix()}-git-${commitHash.slice(0, 12)}.md`;
63
87
  },
64
88
 
@@ -82,9 +106,25 @@ function createGitBackend() {
82
106
  return await gitClient.getLatestCommitIds(config, targetInfo, limit);
83
107
  },
84
108
  async getChangeDiff(config, targetInfo, commitHash) {
109
+ if (commitHash === "UNCOMMITTED") {
110
+ return await gitClient.getUncommittedDiff(config, targetInfo);
111
+ }
85
112
  return await gitClient.getCommitDiff(config, targetInfo, commitHash);
86
113
  },
87
114
  async getChangeDetails(config, targetInfo, commitHash) {
115
+ if (commitHash === "UNCOMMITTED") {
116
+ const details = await gitClient.getUncommittedDetails(config, targetInfo);
117
+
118
+ return {
119
+ id: "UNCOMMITTED",
120
+ displayId: "uncommitted",
121
+ author: details.author,
122
+ date: details.date,
123
+ message: details.message,
124
+ changedPaths: details.changedPaths
125
+ };
126
+ }
127
+
88
128
  const details = await gitClient.getCommitDetails(config, targetInfo, commitHash);
89
129
 
90
130
  return {