kodu 1.1.2 → 1.1.4

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.
Files changed (128) hide show
  1. package/.prettierrc +4 -0
  2. package/AGENTS.md +97 -0
  3. package/README.md +85 -100
  4. package/biome.json +50 -0
  5. package/dist/app.module.d.ts +2 -0
  6. package/dist/app.module.js +42 -0
  7. package/dist/app.module.js.map +1 -0
  8. package/dist/commands/clean/clean.command.d.ts +16 -0
  9. package/dist/commands/clean/clean.command.js +88 -0
  10. package/dist/commands/clean/clean.command.js.map +1 -0
  11. package/dist/commands/clean/clean.module.d.ts +2 -0
  12. package/dist/commands/clean/clean.module.js +25 -0
  13. package/dist/commands/clean/clean.module.js.map +1 -0
  14. package/dist/commands/commit/commit.command.d.ts +18 -0
  15. package/dist/commands/commit/commit.command.js +135 -0
  16. package/dist/commands/commit/commit.command.js.map +1 -0
  17. package/dist/commands/commit/commit.module.d.ts +2 -0
  18. package/dist/commands/commit/commit.module.js +25 -0
  19. package/dist/commands/commit/commit.module.js.map +1 -0
  20. package/dist/commands/init/init.command.d.ts +14 -0
  21. package/dist/commands/init/init.command.js +166 -0
  22. package/dist/commands/init/init.command.js.map +1 -0
  23. package/dist/commands/init/init.module.d.ts +2 -0
  24. package/dist/commands/init/init.module.js +22 -0
  25. package/dist/commands/init/init.module.js.map +1 -0
  26. package/dist/commands/pack/pack.command.d.ts +25 -0
  27. package/dist/commands/pack/pack.command.js +161 -0
  28. package/dist/commands/pack/pack.command.js.map +1 -0
  29. package/dist/commands/pack/pack.module.d.ts +2 -0
  30. package/dist/commands/pack/pack.module.js +25 -0
  31. package/dist/commands/pack/pack.module.js.map +1 -0
  32. package/dist/commands/review/review.command.d.ts +31 -0
  33. package/dist/commands/review/review.command.js +260 -0
  34. package/dist/commands/review/review.command.js.map +1 -0
  35. package/dist/commands/review/review.module.d.ts +2 -0
  36. package/dist/commands/review/review.module.js +26 -0
  37. package/dist/commands/review/review.module.js.map +1 -0
  38. package/dist/core/config/config.module.d.ts +2 -0
  39. package/dist/core/config/config.module.js +22 -0
  40. package/dist/core/config/config.module.js.map +1 -0
  41. package/dist/core/config/config.schema.d.ts +16 -0
  42. package/dist/core/config/config.schema.js +48 -0
  43. package/dist/core/config/config.schema.js.map +1 -0
  44. package/dist/core/config/config.service.d.ts +7 -0
  45. package/dist/core/config/config.service.js +51 -0
  46. package/dist/core/config/config.service.js.map +1 -0
  47. package/dist/core/config/config.types.d.ts +1 -0
  48. package/dist/core/config/config.types.js +3 -0
  49. package/dist/core/config/config.types.js.map +1 -0
  50. package/dist/core/file-system/fs.module.d.ts +2 -0
  51. package/dist/core/file-system/fs.module.js +21 -0
  52. package/dist/core/file-system/fs.module.js.map +1 -0
  53. package/dist/core/file-system/fs.service.d.ts +8 -0
  54. package/dist/core/file-system/fs.service.js +62 -0
  55. package/dist/core/file-system/fs.service.js.map +1 -0
  56. package/dist/core/ui/ui.module.d.ts +2 -0
  57. package/dist/core/ui/ui.module.js +22 -0
  58. package/dist/core/ui/ui.module.js.map +1 -0
  59. package/dist/core/ui/ui.service.d.ts +22 -0
  60. package/dist/core/ui/ui.service.js +43 -0
  61. package/dist/core/ui/ui.service.js.map +1 -0
  62. package/dist/main.d.ts +2 -0
  63. package/dist/main.js +10 -0
  64. package/dist/main.js.map +1 -0
  65. package/dist/shared/ai/ai.module.d.ts +2 -0
  66. package/dist/shared/ai/ai.module.js +23 -0
  67. package/dist/shared/ai/ai.module.js.map +1 -0
  68. package/dist/shared/ai/ai.service.d.ts +27 -0
  69. package/dist/shared/ai/ai.service.js +126 -0
  70. package/dist/shared/ai/ai.service.js.map +1 -0
  71. package/dist/shared/cleaner/cleaner.service.d.ts +18 -0
  72. package/dist/shared/cleaner/cleaner.service.js +187 -0
  73. package/dist/shared/cleaner/cleaner.service.js.map +1 -0
  74. package/dist/shared/cleaner/cleaner.types.d.ts +14 -0
  75. package/dist/shared/cleaner/cleaner.types.js +3 -0
  76. package/dist/shared/cleaner/cleaner.types.js.map +1 -0
  77. package/dist/shared/git/git.module.d.ts +2 -0
  78. package/dist/shared/git/git.module.js +23 -0
  79. package/dist/shared/git/git.module.js.map +1 -0
  80. package/dist/shared/git/git.service.d.ts +11 -0
  81. package/dist/shared/git/git.service.js +61 -0
  82. package/dist/shared/git/git.service.js.map +1 -0
  83. package/dist/shared/tokenizer/tokenizer.module.d.ts +2 -0
  84. package/dist/shared/tokenizer/tokenizer.module.js +23 -0
  85. package/dist/shared/tokenizer/tokenizer.module.js.map +1 -0
  86. package/dist/shared/tokenizer/tokenizer.service.d.ts +15 -0
  87. package/dist/shared/tokenizer/tokenizer.service.js +59 -0
  88. package/dist/shared/tokenizer/tokenizer.service.js.map +1 -0
  89. package/dist/tsconfig.build.tsbuildinfo +1 -0
  90. package/docs/plan.md +92 -0
  91. package/docs/project_charter.md +92 -0
  92. package/docs/todo.md +5 -0
  93. package/knip.json +10 -0
  94. package/kodu.json +22 -0
  95. package/nest-cli.json +8 -0
  96. package/package.json +41 -41
  97. package/src/app.module.ts +29 -0
  98. package/src/commands/clean/clean.command.ts +83 -0
  99. package/src/commands/clean/clean.module.ts +12 -0
  100. package/src/commands/commit/commit.command.ts +120 -0
  101. package/src/commands/commit/commit.module.ts +12 -0
  102. package/src/commands/init/init.command.ts +193 -0
  103. package/src/commands/init/init.module.ts +9 -0
  104. package/src/commands/pack/pack.command.ts +167 -0
  105. package/src/commands/pack/pack.module.ts +12 -0
  106. package/src/commands/review/review.command.ts +266 -0
  107. package/src/commands/review/review.module.ts +13 -0
  108. package/src/core/config/config.module.ts +9 -0
  109. package/src/core/config/config.schema.ts +50 -0
  110. package/src/core/config/config.service.ts +46 -0
  111. package/src/core/config/config.types.ts +1 -0
  112. package/src/core/file-system/fs.module.ts +8 -0
  113. package/src/core/file-system/fs.service.ts +44 -0
  114. package/src/core/ui/ui.module.ts +9 -0
  115. package/src/core/ui/ui.service.ts +39 -0
  116. package/src/main.ts +9 -0
  117. package/src/shared/ai/ai.module.ts +10 -0
  118. package/src/shared/ai/ai.service.ts +160 -0
  119. package/src/shared/cleaner/cleaner.service.ts +227 -0
  120. package/src/shared/cleaner/cleaner.types.ts +16 -0
  121. package/src/shared/git/git.module.ts +10 -0
  122. package/src/shared/git/git.service.ts +49 -0
  123. package/src/shared/tokenizer/tokenizer.module.ts +10 -0
  124. package/src/shared/tokenizer/tokenizer.service.ts +54 -0
  125. package/tsconfig.build.json +4 -0
  126. package/tsconfig.json +25 -0
  127. package/LICENSE +0 -21
  128. package/dist/index.js +0 -146
@@ -0,0 +1,193 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Command, CommandRunner } from 'nest-commander';
4
+ import { type KoduConfig } from '../../core/config/config.schema';
5
+ import { UiService } from '../../core/ui/ui.service';
6
+
7
+ @Command({ name: 'init', description: 'Инициализация конфигурации Kodu' })
8
+ export class InitCommand extends CommandRunner {
9
+ constructor(private readonly ui: UiService) {
10
+ super();
11
+ }
12
+
13
+ async run(): Promise<void> {
14
+ const configPath = path.join(process.cwd(), 'kodu.json');
15
+
16
+ const defaultConfig: KoduConfig = {
17
+ llm: { provider: 'openai', model: 'gpt-4o', apiKeyEnv: 'OPENAI_API_KEY' },
18
+ cleaner: { whitelist: ['//!'], keepJSDoc: true },
19
+ packer: {
20
+ ignore: [
21
+ 'package-lock.json',
22
+ 'yarn.lock',
23
+ 'pnpm-lock.yaml',
24
+ '.git',
25
+ '.kodu',
26
+ 'node_modules',
27
+ 'dist',
28
+ 'coverage',
29
+ ],
30
+ },
31
+ };
32
+
33
+ const provider = await this.ui.promptSelect<'openai'>(
34
+ this.buildProviderQuestion(defaultConfig.llm.provider),
35
+ );
36
+
37
+ const extendIgnore = await this.ui.promptConfirm({
38
+ message: 'Изменить стандартный ignore-список?',
39
+ default: false,
40
+ });
41
+
42
+ const ignoreList = extendIgnore
43
+ ? await this.askIgnoreList(defaultConfig.packer.ignore)
44
+ : defaultConfig.packer.ignore;
45
+
46
+ const additionalWhitelist = await this.ui.promptInput({
47
+ message:
48
+ 'Дополнительные префиксы для whitelist (через запятую, пусто — оставить дефолт):',
49
+ default: '',
50
+ });
51
+
52
+ const whitelist = this.mergeWhitelist(
53
+ defaultConfig.cleaner.whitelist,
54
+ additionalWhitelist,
55
+ );
56
+
57
+ const configToSave: KoduConfig = {
58
+ llm: {
59
+ provider,
60
+ model: defaultConfig.llm.model,
61
+ apiKeyEnv: defaultConfig.llm.apiKeyEnv,
62
+ },
63
+ cleaner: { whitelist, keepJSDoc: defaultConfig.cleaner.keepJSDoc },
64
+ packer: { ignore: ignoreList },
65
+ };
66
+
67
+ await this.writeConfig(configPath, configToSave);
68
+ await this.ensureKoduFolders();
69
+ await this.ensureGitignore();
70
+
71
+ this.ui.log.success('Конфигурация Kodu создана.');
72
+ this.ui.log.info(
73
+ '🎉 Kodu initialized! Запустите `kodu pack`, чтобы продолжить.',
74
+ );
75
+ }
76
+
77
+ private buildProviderQuestion(defaultProvider: 'openai') {
78
+ return {
79
+ message: 'Выберите AI-провайдера',
80
+ choices: [{ name: 'OpenAI', value: 'openai' as const }],
81
+ default: defaultProvider,
82
+ };
83
+ }
84
+
85
+ private async askIgnoreList(defaultIgnore: string[]): Promise<string[]> {
86
+ const answer = await this.ui.promptInput({
87
+ message: 'Укажите ignore-паттерны через запятую',
88
+ default: defaultIgnore.join(', '),
89
+ });
90
+
91
+ return answer
92
+ .split(',')
93
+ .map((item) => item.trim())
94
+ .filter((item) => item.length > 0);
95
+ }
96
+
97
+ private mergeWhitelist(defaultWhitelist: string[], extra: string): string[] {
98
+ if (!extra.trim()) {
99
+ return defaultWhitelist;
100
+ }
101
+
102
+ const additions = extra
103
+ .split(',')
104
+ .map((item) => item.trim())
105
+ .filter((item) => item.length > 0);
106
+
107
+ return Array.from(new Set([...defaultWhitelist, ...additions]));
108
+ }
109
+
110
+ private async writeConfig(
111
+ configPath: string,
112
+ config: KoduConfig,
113
+ ): Promise<void> {
114
+ if (await this.fileExists(configPath)) {
115
+ const overwrite = await this.ui.promptConfirm({
116
+ message: 'kodu.json уже существует. Перезаписать?',
117
+ default: false,
118
+ });
119
+
120
+ if (!overwrite) {
121
+ this.ui.log.warn('Инициализация отменена: файл kodu.json уже есть.');
122
+ return;
123
+ }
124
+ }
125
+
126
+ await fs.writeFile(
127
+ configPath,
128
+ `${JSON.stringify(config, null, 2)}\n`,
129
+ 'utf8',
130
+ );
131
+ this.ui.log.success(`Сохранен ${configPath}`);
132
+ }
133
+
134
+ private async ensureKoduFolders(): Promise<void> {
135
+ const koduDir = path.join(process.cwd(), '.kodu');
136
+ const promptsDir = path.join(koduDir, 'prompts');
137
+
138
+ await fs.mkdir(promptsDir, { recursive: true });
139
+
140
+ const keepFile = path.join(promptsDir, '.keep');
141
+ if (!(await this.fileExists(keepFile))) {
142
+ await fs.writeFile(keepFile, '');
143
+ }
144
+ }
145
+
146
+ private async ensureGitignore(): Promise<void> {
147
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
148
+ const content = (await this.fileExists(gitignorePath))
149
+ ? await fs.readFile(gitignorePath, 'utf8')
150
+ : '';
151
+
152
+ const lines = content.split(/\r?\n/);
153
+ const additions: string[] = [];
154
+
155
+ if (
156
+ !lines.some((line) => line.trim() === '.kodu' || line.trim() === '.kodu/')
157
+ ) {
158
+ additions.push('.kodu/');
159
+ }
160
+
161
+ if (!lines.some((line) => line.trim() === '.env')) {
162
+ const addEnv = await this.ui.promptConfirm({
163
+ message: 'В .gitignore нет .env. Добавить?',
164
+ default: true,
165
+ });
166
+
167
+ if (addEnv) {
168
+ additions.push('.env');
169
+ }
170
+ }
171
+
172
+ if (additions.length === 0) {
173
+ return;
174
+ }
175
+
176
+ const trimmed = content.trimEnd();
177
+ const next =
178
+ trimmed.length > 0
179
+ ? `${trimmed}\n${additions.join('\n')}`
180
+ : additions.join('\n');
181
+ await fs.writeFile(gitignorePath, `${next}\n`, 'utf8');
182
+ this.ui.log.success('Обновлен .gitignore');
183
+ }
184
+
185
+ private async fileExists(targetPath: string): Promise<boolean> {
186
+ try {
187
+ await fs.access(targetPath);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { UiModule } from '../../core/ui/ui.module';
3
+ import { InitCommand } from './init.command';
4
+
5
+ @Module({
6
+ imports: [UiModule],
7
+ providers: [InitCommand],
8
+ })
9
+ export class InitModule {}
@@ -0,0 +1,167 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import clipboard from 'clipboardy';
4
+ import { Command, CommandRunner, Option } from 'nest-commander';
5
+ import { FsService } from '../../core/file-system/fs.service';
6
+ import { UiService } from '../../core/ui/ui.service';
7
+ import { TokenizerService } from '../../shared/tokenizer/tokenizer.service';
8
+
9
+ type PackOptions = {
10
+ copy?: boolean;
11
+ template?: string;
12
+ out?: string;
13
+ };
14
+
15
+ type TemplateContext = {
16
+ context: string;
17
+ fileList: string;
18
+ tokenCount: number;
19
+ usdEstimate: number;
20
+ };
21
+
22
+ @Command({ name: 'pack', description: 'Собрать контекст проекта в один файл' })
23
+ export class PackCommand extends CommandRunner {
24
+ constructor(
25
+ private readonly ui: UiService,
26
+ private readonly fsService: FsService,
27
+ private readonly tokenizer: TokenizerService,
28
+ ) {
29
+ super();
30
+ }
31
+
32
+ @Option({ flags: '-c, --copy', description: 'Скопировать результат в буфер' })
33
+ parseCopy(): boolean {
34
+ return true;
35
+ }
36
+
37
+ @Option({
38
+ flags: '-t, --template <name>',
39
+ description: 'Имя шаблона из .kodu/prompts',
40
+ })
41
+ parseTemplate(value: string): string {
42
+ return value;
43
+ }
44
+
45
+ @Option({
46
+ flags: '-o, --out <path>',
47
+ description: 'Путь для сохранения результата',
48
+ })
49
+ parseOut(value: string): string {
50
+ return value;
51
+ }
52
+
53
+ async run(_inputs: string[], options: PackOptions): Promise<void> {
54
+ const spinner = this.ui.createSpinner({ text: 'Сбор файлов...' }).start();
55
+
56
+ try {
57
+ const files = await this.fsService.findProjectFiles();
58
+
59
+ if (files.length === 0) {
60
+ spinner.stop('Нет файлов для упаковки.');
61
+ this.ui.log.warn('Нет файлов для упаковки.');
62
+ return;
63
+ }
64
+
65
+ const context = await this.buildContext(files);
66
+ const fileList = files.join('\n');
67
+ const { tokens, usdEstimate } = this.tokenizer.count(context);
68
+
69
+ const templateApplied = options.template
70
+ ? await this.applyTemplate(options.template, {
71
+ context,
72
+ fileList,
73
+ tokenCount: tokens,
74
+ usdEstimate,
75
+ })
76
+ : context;
77
+
78
+ const outputPath = await this.writeOutput(templateApplied, options.out);
79
+
80
+ if (options.copy) {
81
+ await clipboard.write(templateApplied);
82
+ }
83
+
84
+ spinner.success('Сбор завершен');
85
+ this.ui.log.info(`Файлов: ${files.length}`);
86
+ this.ui.log.info(`Токены: ${tokens}`);
87
+ this.ui.log.info(`Оценка стоимости: ~$${usdEstimate.toFixed(4)}`);
88
+ this.ui.log.success(`Сохранено в ${outputPath}`);
89
+
90
+ if (options.copy) {
91
+ this.ui.log.success('Результат скопирован в буфер обмена');
92
+ }
93
+ } catch (error) {
94
+ spinner.error('Ошибка при сборке контекста');
95
+ const message =
96
+ error instanceof Error ? error.message : 'Неизвестная ошибка';
97
+ this.ui.log.error(message);
98
+ process.exitCode = 1;
99
+ }
100
+ }
101
+
102
+ private async buildContext(files: string[]): Promise<string> {
103
+ const chunks = await Promise.all(
104
+ files.map(async (file) => {
105
+ const content = await this.fsService.readFileRelative(file);
106
+ return `// file: ${file}\n${content}`;
107
+ }),
108
+ );
109
+
110
+ return chunks.join('\n\n');
111
+ }
112
+
113
+ private async applyTemplate(
114
+ name: string,
115
+ ctx: TemplateContext,
116
+ ): Promise<string> {
117
+ const template = await this.loadTemplate(name);
118
+ const filled = template
119
+ .replace(/\{\{context\}\}/g, ctx.context)
120
+ .replace(/\{\{fileList\}\}/g, ctx.fileList)
121
+ .replace(/\{\{tokenCount\}\}/g, ctx.tokenCount.toString())
122
+ .replace(/\{\{usdEstimate\}\}/g, ctx.usdEstimate.toFixed(4));
123
+
124
+ if (!template.includes('{{context}}')) {
125
+ return `${filled}\n\n${ctx.context}`;
126
+ }
127
+
128
+ return filled;
129
+ }
130
+
131
+ private async loadTemplate(name: string): Promise<string> {
132
+ const base = path.join(process.cwd(), '.kodu', 'prompts', name);
133
+ const candidates = [`${base}.md`, `${base}.txt`];
134
+
135
+ for (const candidate of candidates) {
136
+ if (await this.fileExists(candidate)) {
137
+ return fs.readFile(candidate, 'utf8');
138
+ }
139
+ }
140
+
141
+ throw new Error(
142
+ `Шаблон ${name} не найден. Ожидались файлы: ${candidates
143
+ .map((c) => path.relative(process.cwd(), c))
144
+ .join(', ')}`,
145
+ );
146
+ }
147
+
148
+ private async writeOutput(
149
+ content: string,
150
+ outPath?: string,
151
+ ): Promise<string> {
152
+ const target = outPath ?? path.join(process.cwd(), '.kodu', 'context.txt');
153
+ const dir = path.dirname(target);
154
+ await fs.mkdir(dir, { recursive: true });
155
+ await fs.writeFile(target, `${content}\n`, 'utf8');
156
+ return target;
157
+ }
158
+
159
+ private async fileExists(filePath: string): Promise<boolean> {
160
+ try {
161
+ await fs.access(filePath);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '../../core/config/config.module';
3
+ import { FsModule } from '../../core/file-system/fs.module';
4
+ import { UiModule } from '../../core/ui/ui.module';
5
+ import { TokenizerModule } from '../../shared/tokenizer/tokenizer.module';
6
+ import { PackCommand } from './pack.command';
7
+
8
+ @Module({
9
+ imports: [ConfigModule, UiModule, FsModule, TokenizerModule],
10
+ providers: [PackCommand],
11
+ })
12
+ export class PackModule {}
@@ -0,0 +1,266 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import clipboard from 'clipboardy';
3
+ import { Command, CommandRunner, Option } from 'nest-commander';
4
+ import { UiService } from '../../core/ui/ui.service';
5
+ import {
6
+ AiService,
7
+ type ReviewMode,
8
+ type ReviewResult,
9
+ } from '../../shared/ai/ai.service';
10
+ import { GitService } from '../../shared/git/git.service';
11
+ import { TokenizerService } from '../../shared/tokenizer/tokenizer.service';
12
+
13
+ type ReviewOptions = {
14
+ mode?: ReviewMode;
15
+ copy?: boolean;
16
+ json?: boolean;
17
+ ci?: boolean;
18
+ output?: string;
19
+ };
20
+
21
+ const DEFAULT_MODE: ReviewMode = 'bug';
22
+
23
+ @Command({
24
+ name: 'review',
25
+ description: 'AI ревью для застейдженных изменений',
26
+ })
27
+ export class ReviewCommand extends CommandRunner {
28
+ constructor(
29
+ private readonly ui: UiService,
30
+ private readonly git: GitService,
31
+ private readonly tokenizer: TokenizerService,
32
+ private readonly ai: AiService,
33
+ ) {
34
+ super();
35
+ }
36
+
37
+ @Option({
38
+ flags: '-m, --mode <mode>',
39
+ description: 'Режим проверки: bug | style | security',
40
+ })
41
+ parseMode(value: string): ReviewMode {
42
+ if (value === 'bug' || value === 'style' || value === 'security') {
43
+ return value;
44
+ }
45
+ return DEFAULT_MODE;
46
+ }
47
+
48
+ @Option({ flags: '-c, --copy', description: 'Скопировать результат в буфер' })
49
+ parseCopy(): boolean {
50
+ return true;
51
+ }
52
+
53
+ @Option({
54
+ flags: '--json',
55
+ description: 'Вернуть JSON (структурированный вывод)',
56
+ })
57
+ parseJson(): boolean {
58
+ return true;
59
+ }
60
+
61
+ @Option({ flags: '--ci', description: 'CI-режим: без спиннера и без буфера' })
62
+ parseCi(): boolean {
63
+ return true;
64
+ }
65
+
66
+ @Option({
67
+ flags: '-o, --output <path>',
68
+ description: 'Сохранить итоговый ревью в файл (text/JSON)',
69
+ })
70
+ parseOutput(value: string): string {
71
+ return value;
72
+ }
73
+
74
+ async run(_inputs: string[], options: ReviewOptions = {}): Promise<void> {
75
+ const ciMode = Boolean(options.ci);
76
+ const spinner = ciMode
77
+ ? undefined
78
+ : this.ui.createSpinner({ text: 'Собираю diff из git...' }).start();
79
+
80
+ const logProgress = (text: string): void => {
81
+ if (ciMode) {
82
+ return;
83
+ }
84
+ if (spinner) {
85
+ spinner.text = text;
86
+ return;
87
+ }
88
+ this.ui.log.info(text);
89
+ };
90
+
91
+ const finishProgress = (text: string): void => {
92
+ if (ciMode) {
93
+ return;
94
+ }
95
+ if (spinner) {
96
+ spinner.success(text);
97
+ return;
98
+ }
99
+ this.ui.log.success(text);
100
+ };
101
+
102
+ try {
103
+ await this.git.ensureRepo();
104
+
105
+ const hasStaged = await this.git.hasStagedChanges();
106
+ if (!hasStaged) {
107
+ if (spinner) {
108
+ spinner.stop('Нет застейдженных изменений');
109
+ } else {
110
+ this.ui.log.info('Нет застейдженных изменений');
111
+ }
112
+ this.ui.log.warn('Сначала выполните git add для нужных файлов.');
113
+ return;
114
+ }
115
+
116
+ const diff = await this.git.getStagedDiff();
117
+ if (!diff.trim()) {
118
+ if (spinner) {
119
+ spinner.stop('Diff пуст — возможно, всё исключено packer.ignore');
120
+ } else {
121
+ this.ui.log.info('Diff пуст — возможно, всё исключено packer.ignore');
122
+ }
123
+ this.ui.log.warn(
124
+ 'Diff пустой: все изменения попали в исключения packer.ignore.',
125
+ );
126
+ return;
127
+ }
128
+
129
+ const tokens = this.tokenizer.count(diff);
130
+ const warningBudget = 12000;
131
+ if (tokens.tokens > warningBudget) {
132
+ this.ui.log.warn(
133
+ `Большой контекст (${tokens.tokens} токенов, ~$${tokens.usdEstimate.toFixed(2)}). Ревью может стоить дороже.`,
134
+ );
135
+ }
136
+
137
+ logProgress('Запрос к AI...');
138
+ const mode = options.mode ?? DEFAULT_MODE;
139
+ const result = await this.ai.reviewDiff(
140
+ diff,
141
+ mode,
142
+ Boolean(options.json),
143
+ );
144
+
145
+ finishProgress('Ревью готово');
146
+
147
+ if (options.json && result.structured) {
148
+ this.renderStructured(result.structured, ciMode);
149
+ await this.writeOutput(options.output, result.structured, ciMode);
150
+ if (options.copy) {
151
+ await this.copyJson(result.structured, ciMode);
152
+ }
153
+ this.failIfIssues(result.structured, ciMode);
154
+ return;
155
+ }
156
+
157
+ if (options.json && !result.structured) {
158
+ this.ui.log.warn(
159
+ 'Структурированный вывод недоступен, показываю текст.',
160
+ );
161
+ }
162
+
163
+ console.log(result.text);
164
+ await this.writeOutput(options.output, result.text, ciMode);
165
+
166
+ if (options.copy) {
167
+ await this.copyText(result.text, ciMode);
168
+ }
169
+ this.failIfIssues(result.structured, ciMode);
170
+ } catch (error) {
171
+ if (spinner) {
172
+ spinner.error('Ошибка ревью');
173
+ } else {
174
+ this.ui.log.error('Ошибка ревью');
175
+ }
176
+ const message =
177
+ error instanceof Error ? error.message : 'Неизвестная ошибка';
178
+ this.ui.log.error(message);
179
+ process.exitCode = 1;
180
+ }
181
+ }
182
+
183
+ private async writeOutput(
184
+ target: string | undefined,
185
+ payload: unknown,
186
+ ciMode?: boolean,
187
+ ): Promise<void> {
188
+ if (!target) {
189
+ return;
190
+ }
191
+ const data =
192
+ typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
193
+ await writeFile(target, data, { encoding: 'utf8' });
194
+ if (!ciMode) {
195
+ this.ui.log.success(`Результат сохранён в ${target}`);
196
+ }
197
+ }
198
+
199
+ private async copyJson(result: ReviewResult, ciMode: boolean): Promise<void> {
200
+ if (ciMode) {
201
+ this.ui.log.warn('--copy игнорируется в CI режиме');
202
+ return;
203
+ }
204
+ await clipboard.write(JSON.stringify(result, null, 2));
205
+ this.ui.log.success('JSON скопирован в буфер обмена');
206
+ }
207
+
208
+ private async copyText(text: string, ciMode: boolean): Promise<void> {
209
+ if (ciMode) {
210
+ this.ui.log.warn('--copy игнорируется в CI режиме');
211
+ return;
212
+ }
213
+ await clipboard.write(text);
214
+ this.ui.log.success('Результат скопирован в буфер обмена');
215
+ }
216
+
217
+ private failIfIssues(
218
+ structured: ReviewResult | undefined,
219
+ ciMode: boolean,
220
+ ): void {
221
+ const issues = structured?.issues ?? [];
222
+ if (!issues.length) {
223
+ return;
224
+ }
225
+ const message = 'AI нашёл проблемы — ревью фейлится.';
226
+ if (ciMode) {
227
+ console.error(message);
228
+ } else {
229
+ this.ui.log.error(message);
230
+ }
231
+ process.exitCode = 1;
232
+ }
233
+
234
+ private renderStructured(result: ReviewResult, ciMode: boolean): void {
235
+ if (ciMode) {
236
+ console.log(`Итог: ${result.summary}`);
237
+ if (!result.issues.length) {
238
+ return;
239
+ }
240
+ result.issues.forEach((issue) => {
241
+ const location = [issue.file, issue.line ? `:${issue.line}` : '']
242
+ .filter(Boolean)
243
+ .join('');
244
+ console.log(
245
+ `- [${issue.severity}] ${location ? `${location} ` : ''}${issue.message}`,
246
+ );
247
+ });
248
+ return;
249
+ }
250
+
251
+ this.ui.log.info(`Итог: ${result.summary}`);
252
+ if (!result.issues.length) {
253
+ this.ui.log.success('Критичных проблем не найдено.');
254
+ return;
255
+ }
256
+
257
+ result.issues.forEach((issue) => {
258
+ const location = [issue.file, issue.line ? `:${issue.line}` : '']
259
+ .filter(Boolean)
260
+ .join('');
261
+ console.log(
262
+ `- [${issue.severity}] ${location ? `${location} ` : ''}${issue.message}`,
263
+ );
264
+ });
265
+ }
266
+ }
@@ -0,0 +1,13 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '../../core/config/config.module';
3
+ import { UiModule } from '../../core/ui/ui.module';
4
+ import { AiModule } from '../../shared/ai/ai.module';
5
+ import { GitModule } from '../../shared/git/git.module';
6
+ import { TokenizerModule } from '../../shared/tokenizer/tokenizer.module';
7
+ import { ReviewCommand } from './review.command';
8
+
9
+ @Module({
10
+ imports: [ConfigModule, UiModule, GitModule, TokenizerModule, AiModule],
11
+ providers: [ReviewCommand],
12
+ })
13
+ export class ReviewModule {}
@@ -0,0 +1,9 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { ConfigService } from './config.service';
3
+
4
+ @Global()
5
+ @Module({
6
+ providers: [ConfigService],
7
+ exports: [ConfigService],
8
+ })
9
+ export class ConfigModule {}