kodu 2.0.0 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodu",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "High-performance CLI to prepare codebase for LLMs, automate reviews, and draft commits.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,4 @@
1
+ import { createReadStream } from 'node:fs';
1
2
  import { Command, CommandRunner, Option } from 'nest-commander';
2
3
  import { ConfigService } from '../../core/config/config.service';
3
4
  import { FsService } from '../../core/file-system/fs.service';
@@ -5,9 +6,16 @@ import { UiService } from '../../core/ui/ui.service';
5
6
  import { CleanerService } from '../../shared/cleaner/cleaner.service';
6
7
  import { GitService } from '../../shared/git/git.service';
7
8
 
9
+ const SUPPORTED_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|html|htm)$/i;
10
+
8
11
  type CleanOptions = {
9
12
  dryRun?: boolean;
10
13
  changed?: boolean;
14
+ staged?: boolean;
15
+ backup?: boolean;
16
+ noJsdoc?: boolean;
17
+ verbose?: boolean;
18
+ stdin?: boolean;
11
19
  };
12
20
 
13
21
  @Command({ name: 'clean', description: 'Remove comments from code' })
@@ -22,23 +30,65 @@ export class CleanCommand extends CommandRunner {
22
30
  super();
23
31
  }
24
32
 
25
- @Option({
26
- flags: '-d, --dry-run',
27
- description: 'Show what will be removed',
28
- })
33
+ @Option({ flags: '-d, --dry-run', description: 'Show what will be removed' })
29
34
  parseDryRun(): boolean {
30
35
  return true;
31
36
  }
32
37
 
33
38
  @Option({
34
39
  flags: '-c, --changed',
35
- description: 'Clean only changed files',
40
+ description: 'Clean only git-changed files (staged + unstaged + untracked)',
36
41
  })
37
42
  parseChanged(): boolean {
38
43
  return true;
39
44
  }
40
45
 
41
- async run(_inputs: string[], options: CleanOptions = {}): Promise<void> {
46
+ @Option({
47
+ flags: '-s, --staged',
48
+ description: 'Clean only git-staged files',
49
+ })
50
+ parseStaged(): boolean {
51
+ return true;
52
+ }
53
+
54
+ @Option({
55
+ flags: '-b, --backup',
56
+ description: 'Save originals to .kodu/backup/ before modifying',
57
+ })
58
+ parseBackup(): boolean {
59
+ return true;
60
+ }
61
+
62
+ @Option({
63
+ flags: '-n, --no-jsdoc',
64
+ description: 'Remove JSDoc comments (overrides config keepJSDoc)',
65
+ })
66
+ parseNoJsdoc(): boolean {
67
+ return true;
68
+ }
69
+
70
+ @Option({
71
+ flags: '-v, --verbose',
72
+ description: 'Show all removed comments in dry-run (not just first 3)',
73
+ })
74
+ parseVerbose(): boolean {
75
+ return true;
76
+ }
77
+
78
+ @Option({
79
+ flags: '--stdin',
80
+ description: 'Read from stdin, write cleaned result to stdout',
81
+ })
82
+ parseStdin(): boolean {
83
+ return true;
84
+ }
85
+
86
+ async run(inputs: string[], options: CleanOptions = {}): Promise<void> {
87
+ if (options.stdin) {
88
+ await this.runStdin(options);
89
+ return;
90
+ }
91
+
42
92
  const spinner = this.ui
43
93
  .createSpinner({ text: this.buildSpinnerText(options) })
44
94
  .start();
@@ -53,45 +103,62 @@ export class CleanCommand extends CommandRunner {
53
103
  useGitignore: cleanerConfig.useGitignore,
54
104
  ignore: ignorePatterns,
55
105
  });
56
- const targets = await this.collectTargets(allFiles, options);
106
+
107
+ const targets = await this.collectTargets(allFiles, inputs, options);
57
108
 
58
109
  if (targets.length === 0) {
59
- const noFilesMessage = options.changed
60
- ? 'No changed files to clean.'
61
- : 'No files to clean.';
62
- spinner.stop(noFilesMessage);
63
- this.ui.log.warn(noFilesMessage);
110
+ const msg = this.noFilesMessage(options);
111
+ spinner.stop(msg);
112
+ this.ui.log.warn(msg);
64
113
  return;
65
114
  }
66
115
 
67
116
  const summary = await this.cleaner.cleanFiles(targets, {
68
117
  dryRun: options.dryRun,
118
+ backup: options.backup,
119
+ keepJSDoc: options.noJsdoc ? false : undefined,
120
+ onProgress: (current, total) => {
121
+ spinner.text = `${this.buildSpinnerText(options)} (${current}/${total})`;
122
+ },
69
123
  });
70
124
 
71
125
  spinner.success(
72
126
  options.dryRun ? 'Analysis complete' : 'Cleaning complete',
73
127
  );
74
128
 
129
+ const bytesSaved = summary.bytesBefore - summary.bytesAfter;
130
+ const tokensSaved = Math.round(bytesSaved / 4);
131
+
75
132
  if (options.dryRun) {
76
133
  this.ui.log.info(
77
- `Files to be affected: ${summary.filesChanged}, comments: ${summary.commentsRemoved}`,
134
+ `Files affected: ${summary.filesChanged}/${summary.filesProcessed}, comments: ${summary.commentsRemoved}`,
78
135
  );
79
- summary.reports
80
- .filter((report) => report.removed > 0)
81
- .forEach((report) => {
82
- const previews = report.previews
83
- .map((item) => `"${item}"`)
84
- .join(', ');
85
- this.ui.log.info(
86
- `- ${report.file} (${report.removed}): ${previews}`,
87
- );
88
- });
136
+ this.ui.log.info(`Bytes saved: ${bytesSaved} (~${tokensSaved} tokens)`);
137
+
138
+ const limit = options.verbose ? Number.POSITIVE_INFINITY : 3;
139
+ for (const report of summary.reports.filter((r) => r.removed > 0)) {
140
+ const previews = options.verbose
141
+ ? report.previews
142
+ : report.previews.slice(0, limit);
143
+ const more =
144
+ !options.verbose && report.previews.length > limit
145
+ ? ` +${report.previews.length - limit} more`
146
+ : '';
147
+ this.ui.log.info(
148
+ ` ${report.file} (${report.removed}): ${previews.map((p) => `"${p}"`).join(', ')}${more}`,
149
+ );
150
+ }
89
151
  return;
90
152
  }
91
153
 
92
154
  this.ui.log.success(
93
155
  `Files cleaned: ${summary.filesChanged}, comments removed: ${summary.commentsRemoved}`,
94
156
  );
157
+ this.ui.log.info(`Bytes saved: ${bytesSaved} (~${tokensSaved} tokens)`);
158
+
159
+ if (options.backup && summary.filesChanged > 0) {
160
+ this.ui.log.info('Originals backed up to .kodu/backup/');
161
+ }
95
162
  } catch (error) {
96
163
  spinner.error('Error during cleaning');
97
164
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -100,26 +167,69 @@ export class CleanCommand extends CommandRunner {
100
167
  }
101
168
  }
102
169
 
170
+ private async runStdin(options: CleanOptions): Promise<void> {
171
+ try {
172
+ const input = await this.readStdin();
173
+ const cleaned = this.cleaner.cleanContent(
174
+ 'stdin.ts',
175
+ input,
176
+ options.noJsdoc ? false : undefined,
177
+ );
178
+ process.stdout.write(cleaned);
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : 'Unknown error';
181
+ this.ui.log.error(message);
182
+ process.exitCode = 1;
183
+ }
184
+ }
185
+
186
+ private readStdin(): Promise<string> {
187
+ return new Promise((resolve, reject) => {
188
+ const chunks: Buffer[] = [];
189
+ const stream = createReadStream('/dev/stdin');
190
+ stream.on('data', (chunk) =>
191
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk),
192
+ );
193
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
194
+ stream.on('error', reject);
195
+ });
196
+ }
197
+
103
198
  private buildSpinnerText(options: CleanOptions): string {
104
- const action = options.dryRun ? 'Analysis' : 'Cleaning';
105
- const target = options.changed ? ' changed files' : ' comments';
106
- return `${action}${target}...`;
199
+ if (options.staged) return 'Cleaning staged files...';
200
+ if (options.changed) return 'Cleaning changed files...';
201
+ return options.dryRun ? 'Analysing...' : 'Cleaning...';
202
+ }
203
+
204
+ private noFilesMessage(options: CleanOptions): string {
205
+ if (options.staged) return 'No staged files to clean.';
206
+ if (options.changed) return 'No changed files to clean.';
207
+ return 'No files to clean.';
107
208
  }
108
209
 
109
210
  private async collectTargets(
110
211
  allFiles: string[],
212
+ inputs: string[],
111
213
  options: CleanOptions,
112
214
  ): Promise<string[]> {
113
- const matcher = /\.(ts|tsx|js|jsx|html)$/i;
114
- const filtered = allFiles.filter((file) => matcher.test(file));
215
+ const supported = allFiles.filter((f) => SUPPORTED_EXTENSIONS.test(f));
216
+
217
+ if (inputs.length > 0) {
218
+ return supported.filter((f) =>
219
+ inputs.some((i) => f === i || f.startsWith(`${i.replace(/\/$/, '')}/`)),
220
+ );
221
+ }
115
222
 
116
- if (!options.changed) {
117
- return filtered;
223
+ if (options.staged) {
224
+ const staged = new Set(await this.git.getStagedFiles());
225
+ return supported.filter((f) => staged.has(f));
118
226
  }
119
227
 
120
- const changedFiles = await this.git.getChangedFiles();
121
- const changedSet = new Set(changedFiles);
228
+ if (options.changed) {
229
+ const changed = new Set(await this.git.getChangedFiles());
230
+ return supported.filter((f) => changed.has(f));
231
+ }
122
232
 
123
- return filtered.filter((file) => changedSet.has(file));
233
+ return supported;
124
234
  }
125
235
  }
@@ -6,6 +6,7 @@ import { ConfigService } from '../../core/config/config.service';
6
6
  import { PromptService } from '../../core/config/prompt.service';
7
7
  import { FsService } from '../../core/file-system/fs.service';
8
8
  import { UiService } from '../../core/ui/ui.service';
9
+ import { CleanerService } from '../../shared/cleaner/cleaner.service';
9
10
  import { TokenizerService } from '../../shared/tokenizer/tokenizer.service';
10
11
 
11
12
  type OutputFormat = 'xml' | 'text';
@@ -18,6 +19,7 @@ type PackOptions = {
18
19
  exclude?: string[];
19
20
  list?: boolean;
20
21
  format?: OutputFormat;
22
+ clean?: boolean;
21
23
  };
22
24
 
23
25
  type TemplateContext = {
@@ -38,6 +40,7 @@ export class PackCommand extends CommandRunner {
38
40
  private readonly promptService: PromptService,
39
41
  private readonly fsService: FsService,
40
42
  private readonly tokenizer: TokenizerService,
43
+ private readonly cleaner: CleanerService,
41
44
  ) {
42
45
  super();
43
46
  }
@@ -87,6 +90,14 @@ export class PackCommand extends CommandRunner {
87
90
  return true;
88
91
  }
89
92
 
93
+ @Option({
94
+ flags: '--clean',
95
+ description: 'Strip comments in-memory before packing (files not modified)',
96
+ })
97
+ parseClean(): boolean {
98
+ return true;
99
+ }
100
+
90
101
  @Option({
91
102
  flags: '-f, --format <format>',
92
103
  description: 'Output format: xml (default) or text',
@@ -130,7 +141,7 @@ export class PackCommand extends CommandRunner {
130
141
  }
131
142
 
132
143
  const format: OutputFormat = options.format ?? 'xml';
133
- const context = await this.buildContext(files, format);
144
+ const context = await this.buildContext(files, format, options.clean);
134
145
  const fileList = files.join('\n');
135
146
  const { tokens, usdEstimate } = this.tokenizer.count(context);
136
147
 
@@ -160,7 +171,9 @@ export class PackCommand extends CommandRunner {
160
171
  this.ui.log.info(`Files: ${files.length}`);
161
172
  this.ui.log.info(`Tokens: ${tokens}`);
162
173
  this.ui.log.info(`Cost estimate: ~$${usdEstimate.toFixed(4)}`);
163
- this.ui.log.info(`Format: ${format}`);
174
+ this.ui.log.info(
175
+ `Format: ${format}${options.clean ? ' (comments stripped)' : ''}`,
176
+ );
164
177
  this.ui.log.success(`Saved to ${outputPath}`);
165
178
 
166
179
  if (options.copy) {
@@ -177,10 +190,14 @@ export class PackCommand extends CommandRunner {
177
190
  private async buildContext(
178
191
  files: string[],
179
192
  format: OutputFormat,
193
+ clean = false,
180
194
  ): Promise<string> {
181
195
  const chunks = await Promise.all(
182
196
  files.map(async (file) => {
183
- const content = await this.fsService.readFileRelative(file);
197
+ let content = await this.fsService.readFileRelative(file);
198
+ if (clean) {
199
+ content = this.cleaner.cleanContent(file, content);
200
+ }
184
201
  if (format === 'xml') {
185
202
  return `<file path="${file}">\n${content}\n</file>`;
186
203
  }
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
2
2
  import { ConfigModule } from '../../core/config/config.module';
3
3
  import { FsModule } from '../../core/file-system/fs.module';
4
4
  import { UiModule } from '../../core/ui/ui.module';
5
+ import { CleanerService } from '../../shared/cleaner/cleaner.service';
5
6
  import { TokenizerModule } from '../../shared/tokenizer/tokenizer.module';
6
7
  import { PackCommand } from './pack.command';
7
8
 
8
9
  @Module({
9
10
  imports: [ConfigModule, UiModule, FsModule, TokenizerModule],
10
- providers: [PackCommand],
11
+ providers: [PackCommand, CleanerService],
11
12
  })
12
13
  export class PackModule {}
@@ -43,30 +43,50 @@ export class CleanerService {
43
43
  private readonly fsService: FsService,
44
44
  ) {}
45
45
 
46
+ cleanContent(filename: string, content: string, keepJSDoc?: boolean): string {
47
+ const config = this.configService.getConfig();
48
+ const whitelist = this.buildWhitelist(config.cleaner.whitelist);
49
+ const shouldKeepJSDoc = keepJSDoc ?? config.cleaner.keepJSDoc;
50
+ const result = this.cleanSource(
51
+ filename,
52
+ content,
53
+ whitelist,
54
+ shouldKeepJSDoc,
55
+ );
56
+ return result.nextContent;
57
+ }
58
+
46
59
  async cleanFiles(
47
60
  files: string[],
48
61
  options: CleanOptions = {},
49
62
  ): Promise<CleanSummary> {
50
63
  const config = this.configService.getConfig();
51
64
  const whitelist = this.buildWhitelist(config.cleaner.whitelist);
65
+ const keepJSDoc = options.keepJSDoc ?? config.cleaner.keepJSDoc;
52
66
  let commentsRemoved = 0;
53
67
  let filesChanged = 0;
68
+ let bytesBefore = 0;
69
+ let bytesAfter = 0;
54
70
  const reports: FileCleanReport[] = [];
55
71
 
56
- for (const file of files) {
72
+ for (let i = 0; i < files.length; i++) {
73
+ const file = files[i] as string;
74
+ options.onProgress?.(i + 1, files.length);
75
+
57
76
  const original = await this.fsService.readFileRelative(file);
58
- const result = this.cleanSource(
59
- file,
60
- original,
61
- whitelist,
62
- config.cleaner.keepJSDoc,
63
- );
77
+ bytesBefore += Buffer.byteLength(original, 'utf8');
78
+
79
+ const result = this.cleanSource(file, original, whitelist, keepJSDoc);
80
+ bytesAfter += Buffer.byteLength(result.nextContent, 'utf8');
64
81
 
65
82
  if (result.removed > 0) {
66
83
  filesChanged += 1;
67
84
  commentsRemoved += result.removed;
68
85
 
69
86
  if (!options.dryRun) {
87
+ if (options.backup) {
88
+ await this.backupFile(file, original);
89
+ }
70
90
  await this.writeFile(file, result.nextContent);
71
91
  }
72
92
  }
@@ -75,6 +95,8 @@ export class CleanerService {
75
95
  file,
76
96
  removed: result.removed,
77
97
  previews: result.previews,
98
+ bytesBefore: Buffer.byteLength(original, 'utf8'),
99
+ bytesAfter: Buffer.byteLength(result.nextContent, 'utf8'),
78
100
  });
79
101
  }
80
102
 
@@ -82,6 +104,8 @@ export class CleanerService {
82
104
  filesProcessed: files.length,
83
105
  filesChanged,
84
106
  commentsRemoved,
107
+ bytesBefore,
108
+ bytesAfter,
85
109
  reports,
86
110
  };
87
111
  }
@@ -106,9 +130,9 @@ export class CleanerService {
106
130
  return { nextContent: content, removed: 0, previews: [] };
107
131
  }
108
132
 
109
- const previews = candidates
110
- .slice(0, 3)
111
- .map((range) => this.normalizePreview(range.text));
133
+ const previews = candidates.map((range) =>
134
+ this.normalizePreview(range.text),
135
+ );
112
136
 
113
137
  const sorted = [...candidates].sort((a, b) => b.start - a.start);
114
138
  let nextContent = fullText;
@@ -191,8 +215,8 @@ export class CleanerService {
191
215
 
192
216
  private normalizePreview(text: string): string {
193
217
  const singleLine = text.replace(/\s+/g, ' ').trim();
194
- if (singleLine.length <= 50) return singleLine;
195
- return `${singleLine.slice(0, 47)}...`;
218
+ if (singleLine.length <= 60) return singleLine;
219
+ return `${singleLine.slice(0, 57)}...`;
196
220
  }
197
221
 
198
222
  private getReplacement(original: string, range: RemovalRange): string {
@@ -221,6 +245,13 @@ export class CleanerService {
221
245
  await fs.writeFile(absolute, content, 'utf8');
222
246
  }
223
247
 
248
+ private async backupFile(file: string, content: string): Promise<void> {
249
+ const backupDir = path.join(process.cwd(), '.kodu', 'backup');
250
+ const target = path.join(backupDir, file);
251
+ await fs.mkdir(path.dirname(target), { recursive: true });
252
+ await fs.writeFile(target, content, 'utf8');
253
+ }
254
+
224
255
  private addRange(
225
256
  ranges: Map<string, RemovalRange>,
226
257
  start: number,
@@ -1,16 +1,23 @@
1
1
  export type CleanOptions = {
2
2
  dryRun?: boolean;
3
+ backup?: boolean;
4
+ keepJSDoc?: boolean;
5
+ onProgress?: (current: number, total: number) => void;
3
6
  };
4
7
 
5
8
  export type FileCleanReport = {
6
9
  file: string;
7
10
  removed: number;
8
11
  previews: string[];
12
+ bytesBefore: number;
13
+ bytesAfter: number;
9
14
  };
10
15
 
11
16
  export type CleanSummary = {
12
17
  filesProcessed: number;
13
18
  filesChanged: number;
14
19
  commentsRemoved: number;
20
+ bytesBefore: number;
21
+ bytesAfter: number;
15
22
  reports: FileCleanReport[];
16
23
  };
@@ -34,4 +34,14 @@ export class GitService {
34
34
  await load(['ls-files', '--others', '--exclude-standard']);
35
35
  return [...changed].sort();
36
36
  }
37
+
38
+ async getStagedFiles(): Promise<string[]> {
39
+ await this.ensureRepo();
40
+ const { stdout } = await execa('git', ['diff', '--name-only', '--staged']);
41
+ return stdout
42
+ .split('\n')
43
+ .map((entry) => entry.trim())
44
+ .filter((entry) => entry.length > 0)
45
+ .sort();
46
+ }
37
47
  }