kodevu 0.1.52 → 0.1.54

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
@@ -32,6 +32,7 @@ npx kodevu [target] [options]
32
32
  - `--reviewer, -r`: `codex`, `gemini`, `copilot`, `openai`, or `auto` (default: `auto`).
33
33
  - `--rev, -v`: A specific revision or commit hash to review.
34
34
  - `--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.
35
+ - `--uncommitted, -u`: Review current uncommitted changes in the target working tree.
35
36
  - `--lang, -l`: Output language (e.g., `zh`, `en`, `auto`).
36
37
  - `--prompt, -p`: Additional instructions for the reviewer. Use `@file.txt` to read from a file.
37
38
  - `--output, -o`: Report output directory (default: `~/.kodevu`).
@@ -47,6 +48,9 @@ npx kodevu [target] [options]
47
48
  > [!IMPORTANT]
48
49
  > `--rev` and `--last` are mutually exclusive. Specifying both will result in an error.
49
50
 
51
+ > [!IMPORTANT]
52
+ > `--uncommitted` is mutually exclusive with `--rev` and `--last`.
53
+
50
54
  ### Environment Variables
51
55
 
52
56
  You can set these in your shell to change default behavior without typing flags every time:
@@ -81,6 +85,11 @@ Review a **specific commit** hash:
81
85
  npx kodevu . --rev abc1234
82
86
  ```
83
87
 
88
+ Review **current uncommitted changes** (Git/SVN working copy):
89
+ ```bash
90
+ npx kodevu . --uncommitted
91
+ ```
92
+
84
93
  ### Options & Formatting
85
94
 
86
95
  Review using **custom instructions** from a file:
package/SKILL.md ADDED
@@ -0,0 +1,63 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Kodevu Skill
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.
9
+
10
+ ## Usage
11
+
12
+ Use `npx kodevu` to review a codebase. It is designed to be stateless and requires no configuration files.
13
+
14
+ ### Reviewing the latest commit
15
+
16
+ ```bash
17
+ npx kodevu .
18
+ ```
19
+
20
+ ### Reviewing a specific commit
21
+
22
+ ```bash
23
+ npx kodevu . --rev <commit-hash>
24
+ ```
25
+
26
+ ### Reviewing the last N commits
27
+
28
+ ```bash
29
+ npx kodevu . --last 3
30
+ ```
31
+
32
+ ### Supported Reviewers
33
+
34
+ `kodevu` supports several AI reviewer CLIs: `auto`, `openai`, `gemini`, `codex`, `copilot`. The default is `auto`. Use the `--reviewer` option to override.
35
+
36
+ Example using OpenAI:
37
+ ```bash
38
+ npx kodevu . --reviewer openai --openai-api-key <YOUR_API_KEY> --openai-model gpt-4o
39
+ ```
40
+
41
+ ### Generating JSON Reports
42
+
43
+ By default, review reports are generated as Markdown files in `~/.kodevu/`. You can specify `--format json` or change the output directory using `--output <dir>`.
44
+ ```bash
45
+ npx kodevu . --format json --output ./reports
46
+ ```
47
+
48
+ ### Formatting the Prompt
49
+
50
+ You can provide clear instructions to the reviewer using `--prompt`:
51
+ ```bash
52
+ npx kodevu . --prompt "Focus on security issues and suggest optimizations."
53
+ ```
54
+ Or from a file: `--prompt @my-rules.txt`
55
+
56
+ ## Working with Target Repositories
57
+
58
+ - `target`: Repository path (Git) or SVN URL/Working copy (default: `.`).
59
+
60
+ For example, to run on a specific path, you can use:
61
+ ```bash
62
+ npx kodevu /path/to/project --last 1
63
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
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.",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
- "README.md"
12
+ "README.md",
13
+ "SKILL.md"
13
14
  ],
14
15
  "scripts": {
15
16
  "start": "node src/index.js",
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/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 {