cciwon-code-review-cli 2.0.5 → 2.0.7

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.
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "cciwon-code-review-cli",
3
+ "version": "2.0.6",
4
+ "description": "AI-powered code review CLI tool using Qwen3-Coder-30B model with IP whitelist support",
5
+ "main": "lib/api-client.js",
6
+ "bin": {
7
+ "code-review": "bin/code-review.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "code-review",
14
+ "ai",
15
+ "qwen",
16
+ "qwen3-coder",
17
+ "code-analysis",
18
+ "security",
19
+ "llm",
20
+ "code-quality",
21
+ "static-analysis"
22
+ ],
23
+ "author": "cciwon",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "axios": "^1.6.0",
27
+ "commander": "^11.1.0",
28
+ "chalk": "^4.1.2",
29
+ "ora": "^5.4.1",
30
+ "fast-glob": "^3.3.2",
31
+ "readline": "^1.3.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=14.0.0"
35
+ }
36
+ }
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ // bin/code-review.js
3
+ const { Command } = require('commander');
4
+ const chalk = require('chalk');
5
+ const ora = require('ora');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { execSync } = require('child_process');
9
+ const os = require('os');
10
+ const CodeReviewClient = require('../lib/api-client');
11
+ const { parseDiffAndShowInVSCode } = require('../lib/diff-viewer');
12
+
13
+ const program = new Command();
14
+
15
+ // CLI 설정
16
+ program
17
+ .name('code-review')
18
+ .description('AI-powered code review CLI using Qwen3-Coder model')
19
+ .version('1.0.0');
20
+
21
+ // 서버 URL 설정 (환경변수 또는 기본값)
22
+ const SERVER_URL = process.env.CODE_REVIEW_SERVER || 'http://211.56.247.71:8000';
23
+ const client = new CodeReviewClient(SERVER_URL);
24
+
25
+ // ==========================================
26
+ // Diff 파싱 및 VSCode 표시 함수
27
+ // ==========================================
28
+ function parseDiffAndShowInVSCode(reviewText, originalFilePath) {
29
+ const lines = reviewText.split('\n');
30
+ const diffs = [];
31
+ let currentDiff = null;
32
+ let reviewContent = [];
33
+ let inDiffBlock = false;
34
+
35
+ // 디버깅: 전체 응답 확인
36
+ console.log(chalk.gray('\n[디버그] 응답 텍스트 일부:'));
37
+ console.log(chalk.gray(reviewText.substring(0, 500) + '...\n'));
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+
42
+ // unified diff 시작 감지 (--- 와 +++ 패턴)
43
+ // "--- 원본" 또는 "--- a/file" 형식도 지원
44
+ if ((line.startsWith('---') || line.includes('--- ')) &&
45
+ i + 1 < lines.length &&
46
+ (lines[i + 1].startsWith('+++') || lines[i + 1].includes('+++ '))) {
47
+ inDiffBlock = true;
48
+ currentDiff = {
49
+ filename: null,
50
+ diffLines: [line, lines[i + 1]]
51
+ };
52
+ console.log(chalk.yellow(`\n[디버그] diff 블록 발견: ${line}`));
53
+ i++; // +++ 라인 건너뛰기
54
+ continue;
55
+ }
56
+
57
+ // diff 블록 내부
58
+ if (inDiffBlock) {
59
+ if (line.startsWith('@@') || line.startsWith('-') || line.startsWith('+') || line.startsWith(' ')) {
60
+ currentDiff.diffLines.push(line);
61
+ } else if (line.trim() === '' || line.startsWith('===')) {
62
+ // diff 블록 종료
63
+ if (currentDiff) {
64
+ diffs.push(currentDiff);
65
+ console.log(chalk.yellow(`[디버그] diff 블록 종료 (라인 수: ${currentDiff.diffLines.length})`));
66
+ currentDiff = null;
67
+ }
68
+ inDiffBlock = false;
69
+ reviewContent.push(line);
70
+ } else {
71
+ currentDiff.diffLines.push(line);
72
+ }
73
+ } else {
74
+ // 일반 리뷰 텍스트
75
+ reviewContent.push(line);
76
+ }
77
+ }
78
+
79
+ // 마지막 diff 저장
80
+ if (currentDiff) {
81
+ diffs.push(currentDiff);
82
+ console.log(chalk.yellow(`[디버그] 마지막 diff 블록 저장 (라인 수: ${currentDiff.diffLines.length})`));
83
+ }
84
+
85
+ console.log(chalk.cyan(`\n[디버그] 총 ${diffs.length}개의 diff 블록 발견\n`));
86
+
87
+ // diff가 있으면 VSCode로 표시
88
+ if (diffs.length > 0) {
89
+ diffs.forEach((diff, index) => {
90
+ try {
91
+ // 임시 디렉토리 생성
92
+ const tmpDir = path.join(os.tmpdir(), `code-review-${Date.now()}-${index}`);
93
+ fs.mkdirSync(tmpDir, { recursive: true });
94
+
95
+ // 원본/수정본 파일 생성
96
+ const originalFile = path.join(tmpDir, 'original' + path.extname(originalFilePath));
97
+ const modifiedFile = path.join(tmpDir, 'modified' + path.extname(originalFilePath));
98
+
99
+ // diff에서 원본/수정본 코드 추출
100
+ const { original, modified } = extractCodeFromDiff(diff.diffLines, originalFilePath);
101
+
102
+ fs.writeFileSync(originalFile, original);
103
+ fs.writeFileSync(modifiedFile, modified);
104
+
105
+ // VSCode diff 뷰어 열기
106
+ try {
107
+ execSync(`code --diff "${originalFile}" "${modifiedFile}"`, { stdio: 'ignore' });
108
+ console.log(chalk.green(`\n📝 VSCode에서 diff를 열었습니다 (${index + 1}/${diffs.length})`));
109
+ } catch (e) {
110
+ console.log(chalk.yellow(`\n⚠️ VSCode를 열 수 없습니다. diff를 수동으로 확인하세요:`));
111
+ console.log(chalk.gray(diff.diffLines.join('\n')));
112
+ }
113
+
114
+ // 임시 파일은 5분 후 자동 삭제
115
+ setTimeout(() => {
116
+ try {
117
+ fs.unlinkSync(originalFile);
118
+ fs.unlinkSync(modifiedFile);
119
+ fs.rmdirSync(tmpDir);
120
+ } catch (e) {
121
+ // 무시
122
+ }
123
+ }, 5 * 60 * 1000);
124
+
125
+ } catch (error) {
126
+ console.error(chalk.red(`diff 처리 실패: ${error.message}`));
127
+ }
128
+ });
129
+ }
130
+
131
+ // 리뷰 텍스트만 반환
132
+ return reviewContent.join('\n').trim();
133
+ }
134
+
135
+ // diff에서 원본/수정본 추출
136
+ function extractCodeFromDiff(diffLines, originalFilePath) {
137
+ let original = '';
138
+ let modified = '';
139
+
140
+ // 원본 파일이 있으면 읽기
141
+ if (fs.existsSync(originalFilePath)) {
142
+ original = fs.readFileSync(originalFilePath, 'utf-8');
143
+ }
144
+
145
+ // diff 라인을 파싱해서 수정본 생성
146
+ const originalLines = original.split('\n');
147
+ const modifiedLines = [];
148
+
149
+ let lineNum = 0;
150
+ for (const line of diffLines) {
151
+ if (line.startsWith('@@')) {
152
+ // @@ -15,2 +15,2 @@ 형식 파싱
153
+ const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
154
+ if (match) {
155
+ lineNum = parseInt(match[1]) - 1;
156
+ }
157
+ } else if (line.startsWith('-')) {
158
+ // 삭제된 라인 (원본에만 있음)
159
+ lineNum++;
160
+ } else if (line.startsWith('+')) {
161
+ // 추가된 라인 (수정본에만 있음)
162
+ modifiedLines.push(line.substring(1));
163
+ } else if (line.startsWith(' ')) {
164
+ // 변경 없는 라인
165
+ if (lineNum < originalLines.length) {
166
+ modifiedLines.push(originalLines[lineNum]);
167
+ lineNum++;
168
+ } else {
169
+ modifiedLines.push(line.substring(1));
170
+ }
171
+ }
172
+ }
173
+
174
+ // 수정본이 비어있으면 원본을 기반으로 단순 패치 시도
175
+ if (modifiedLines.length === 0) {
176
+ modified = original;
177
+ } else {
178
+ modified = modifiedLines.join('\n');
179
+ }
180
+
181
+ return { original, modified };
182
+ }
183
+
184
+ // ==========================================
185
+ // 폴더 리뷰 명령어
186
+ // ==========================================
187
+ program
188
+ .command('folder <path>')
189
+ .description('폴더 전체를 리뷰합니다')
190
+ .option('-i, --include <patterns>', '포함할 파일 패턴 (쉼표로 구분)', '*.py,*.js,*.ts,*.java,*.cpp,*.go')
191
+ .option('-e, --exclude <patterns>', '제외할 패턴 (쉼표로 구분)', 'node_modules,__pycache__,.git,*.pyc')
192
+ .option('-m, --max-files <number>', '최대 파일 수', '50')
193
+ .action(async (folderPath, options) => {
194
+ const spinner = ora('폴더 리뷰 진행 중...').start();
195
+
196
+ try {
197
+ // 절대 경로로 변환
198
+ const absolutePath = path.resolve(folderPath);
199
+
200
+ // 폴더 존재 확인
201
+ if (!fs.existsSync(absolutePath)) {
202
+ spinner.fail(chalk.red(`❌ 폴더가 존재하지 않습니다: ${absolutePath}`));
203
+ process.exit(1);
204
+ }
205
+
206
+ // API 호출
207
+ const result = await client.reviewFolder(absolutePath, {
208
+ includePatterns: options.include.split(','),
209
+ excludePatterns: options.exclude.split(','),
210
+ maxFiles: parseInt(options.maxFiles)
211
+ });
212
+
213
+ spinner.succeed(chalk.green(`✅ 리뷰 완료! (분석된 파일: ${result.files_analyzed}개)`));
214
+
215
+ // 결과 출력
216
+ console.log('\n' + chalk.bold.cyan('=== 코드 리뷰 결과 ==='));
217
+ console.log(result.review);
218
+
219
+ if (result.warnings.length > 0) {
220
+ console.log('\n' + chalk.yellow('⚠️ 경고:'));
221
+ result.warnings.forEach(warning => {
222
+ console.log(chalk.yellow(` - ${warning}`));
223
+ });
224
+ }
225
+
226
+ } catch (error) {
227
+ spinner.fail(chalk.red('❌ 리뷰 실패'));
228
+ console.error(chalk.red(error.message));
229
+ process.exit(1);
230
+ }
231
+ });
232
+
233
+ // ==========================================
234
+ // 파일 리뷰 명령어
235
+ // ==========================================
236
+ program
237
+ .command('file <path>')
238
+ .description('단일 파일을 리뷰합니다')
239
+ .option('-l, --language <lang>', '프로그래밍 언어', 'python')
240
+ .option('-c, --checklist <path>', '체크리스트 파일 경로')
241
+ .action(async (filePath, options) => {
242
+ const spinner = ora('파일 리뷰 진행 중...').start();
243
+
244
+ try {
245
+ // 절대 경로로 변환
246
+ const absolutePath = path.resolve(filePath);
247
+
248
+ // 파일 존재 확인
249
+ if (!fs.existsSync(absolutePath)) {
250
+ spinner.fail(chalk.red(`❌ 파일이 존재하지 않습니다: ${absolutePath}`));
251
+ process.exit(1);
252
+ }
253
+
254
+ // 파일 읽기
255
+ const code = fs.readFileSync(absolutePath, 'utf-8');
256
+
257
+ // 체크리스트 읽기 (옵션)
258
+ let checklist = null;
259
+ if (options.checklist) {
260
+ const checklistPath = path.resolve(options.checklist);
261
+ if (fs.existsSync(checklistPath)) {
262
+ checklist = fs.readFileSync(checklistPath, 'utf-8');
263
+ } else {
264
+ spinner.warn(chalk.yellow(`⚠️ 체크리스트 파일을 찾을 수 없습니다: ${checklistPath}`));
265
+ }
266
+ }
267
+
268
+ // API 호출
269
+ const result = await client.reviewCode(code, options.language, checklist);
270
+
271
+ spinner.succeed(chalk.green('✅ 리뷰 완료!'));
272
+
273
+ // diff를 파싱하고 VSCode로 표시, 리뷰 텍스트만 반환
274
+ const reviewTextOnly = parseDiffAndShowInVSCode(result.review, absolutePath);
275
+
276
+ // 결과 출력 (리뷰 텍스트만)
277
+ console.log('\n' + chalk.bold.cyan('=== 코드 리뷰 결과 ==='));
278
+ console.log(reviewTextOnly);
279
+
280
+ } catch (error) {
281
+ spinner.fail(chalk.red('❌ 리뷰 실패'));
282
+ console.error(chalk.red(error.message));
283
+ process.exit(1);
284
+ }
285
+ });
286
+
287
+ // ==========================================
288
+ // 헬스 체크 명령어
289
+ // ==========================================
290
+ program
291
+ .command('health')
292
+ .description('서버 상태를 확인합니다')
293
+ .action(async () => {
294
+ const spinner = ora('서버 연결 확인 중...').start();
295
+
296
+ try {
297
+ const health = await client.healthCheck();
298
+ spinner.succeed(chalk.green('✅ 서버 정상 작동 중'));
299
+
300
+ console.log(chalk.cyan('\n서버 정보:'));
301
+ console.log(` - 상태: ${chalk.green(health.status)}`);
302
+ console.log(` - 모델 로드: ${health.model_loaded ? chalk.green('완료') : chalk.red('실패')}`);
303
+ console.log(` - 서버 URL: ${chalk.blue(SERVER_URL)}`);
304
+
305
+ } catch (error) {
306
+ spinner.fail(chalk.red('❌ 서버 연결 실패'));
307
+ console.error(chalk.red(error.message));
308
+ process.exit(1);
309
+ }
310
+ });
311
+
312
+ // ==========================================
313
+ // 인터랙티브 Chat 모드
314
+ // ==========================================
315
+ program
316
+ .command('chat')
317
+ .description('대화형 코드 리뷰 모드를 시작합니다')
318
+ .action(async () => {
319
+ const ChatMode = require('../lib/chat-mode');
320
+ const chatMode = new ChatMode();
321
+
322
+ try {
323
+ await chatMode.start();
324
+ } catch (error) {
325
+ console.error(chalk.red(`\n❌ 에러 발생: ${error.message}`));
326
+ process.exit(1);
327
+ }
328
+ });
329
+
330
+ // ==========================================
331
+ // CLI 실행
332
+ // ==========================================
333
+ program.parse(process.argv);
334
+
335
+ // 명령어가 없으면 help 출력
336
+ if (!process.argv.slice(2).length) {
337
+ program.outputHelp();
338
+ }
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const { execSync } = require('child_process');
9
9
  const os = require('os');
10
10
  const CodeReviewClient = require('../lib/api-client');
11
+ const { parseDiffAndShowInVSCode } = require('../lib/diff-viewer');
11
12
 
12
13
  const program = new Command();
13
14
 
@@ -21,153 +22,6 @@ program
21
22
  const SERVER_URL = process.env.CODE_REVIEW_SERVER || 'http://211.56.247.71:8000';
22
23
  const client = new CodeReviewClient(SERVER_URL);
23
24
 
24
- // ==========================================
25
- // Diff 파싱 및 VSCode 표시 함수
26
- // ==========================================
27
- function parseDiffAndShowInVSCode(reviewText, originalFilePath) {
28
- const lines = reviewText.split('\n');
29
- const diffs = [];
30
- let currentDiff = null;
31
- let reviewContent = [];
32
- let inDiffBlock = false;
33
-
34
- for (let i = 0; i < lines.length; i++) {
35
- const line = lines[i];
36
-
37
- // unified diff 시작 감지 (--- 와 +++ 패턴)
38
- if (line.startsWith('---') && i + 1 < lines.length && lines[i + 1].startsWith('+++')) {
39
- inDiffBlock = true;
40
- currentDiff = {
41
- filename: null,
42
- diffLines: [line, lines[i + 1]]
43
- };
44
- i++; // +++ 라인 건너뛰기
45
- continue;
46
- }
47
-
48
- // diff 블록 내부
49
- if (inDiffBlock) {
50
- if (line.startsWith('@@') || line.startsWith('-') || line.startsWith('+') || line.startsWith(' ')) {
51
- currentDiff.diffLines.push(line);
52
- } else if (line.trim() === '' || line.startsWith('===')) {
53
- // diff 블록 종료
54
- if (currentDiff) {
55
- diffs.push(currentDiff);
56
- currentDiff = null;
57
- }
58
- inDiffBlock = false;
59
- reviewContent.push(line);
60
- } else {
61
- currentDiff.diffLines.push(line);
62
- }
63
- } else {
64
- // 일반 리뷰 텍스트
65
- reviewContent.push(line);
66
- }
67
- }
68
-
69
- // 마지막 diff 저장
70
- if (currentDiff) {
71
- diffs.push(currentDiff);
72
- }
73
-
74
- // diff가 있으면 VSCode로 표시
75
- if (diffs.length > 0) {
76
- diffs.forEach((diff, index) => {
77
- try {
78
- // 임시 디렉토리 생성
79
- const tmpDir = path.join(os.tmpdir(), `code-review-${Date.now()}-${index}`);
80
- fs.mkdirSync(tmpDir, { recursive: true });
81
-
82
- // 원본/수정본 파일 생성
83
- const originalFile = path.join(tmpDir, 'original' + path.extname(originalFilePath));
84
- const modifiedFile = path.join(tmpDir, 'modified' + path.extname(originalFilePath));
85
-
86
- // diff에서 원본/수정본 코드 추출
87
- const { original, modified } = extractCodeFromDiff(diff.diffLines, originalFilePath);
88
-
89
- fs.writeFileSync(originalFile, original);
90
- fs.writeFileSync(modifiedFile, modified);
91
-
92
- // VSCode diff 뷰어 열기
93
- try {
94
- execSync(`code --diff "${originalFile}" "${modifiedFile}"`, { stdio: 'ignore' });
95
- console.log(chalk.green(`\n📝 VSCode에서 diff를 열었습니다 (${index + 1}/${diffs.length})`));
96
- } catch (e) {
97
- console.log(chalk.yellow(`\n⚠️ VSCode를 열 수 없습니다. diff를 수동으로 확인하세요:`));
98
- console.log(chalk.gray(diff.diffLines.join('\n')));
99
- }
100
-
101
- // 임시 파일은 5분 후 자동 삭제
102
- setTimeout(() => {
103
- try {
104
- fs.unlinkSync(originalFile);
105
- fs.unlinkSync(modifiedFile);
106
- fs.rmdirSync(tmpDir);
107
- } catch (e) {
108
- // 무시
109
- }
110
- }, 5 * 60 * 1000);
111
-
112
- } catch (error) {
113
- console.error(chalk.red(`diff 처리 실패: ${error.message}`));
114
- }
115
- });
116
- }
117
-
118
- // 리뷰 텍스트만 반환
119
- return reviewContent.join('\n').trim();
120
- }
121
-
122
- // diff에서 원본/수정본 추출
123
- function extractCodeFromDiff(diffLines, originalFilePath) {
124
- let original = '';
125
- let modified = '';
126
-
127
- // 원본 파일이 있으면 읽기
128
- if (fs.existsSync(originalFilePath)) {
129
- original = fs.readFileSync(originalFilePath, 'utf-8');
130
- }
131
-
132
- // diff 라인을 파싱해서 수정본 생성
133
- const originalLines = original.split('\n');
134
- const modifiedLines = [];
135
-
136
- let lineNum = 0;
137
- for (const line of diffLines) {
138
- if (line.startsWith('@@')) {
139
- // @@ -15,2 +15,2 @@ 형식 파싱
140
- const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
141
- if (match) {
142
- lineNum = parseInt(match[1]) - 1;
143
- }
144
- } else if (line.startsWith('-')) {
145
- // 삭제된 라인 (원본에만 있음)
146
- lineNum++;
147
- } else if (line.startsWith('+')) {
148
- // 추가된 라인 (수정본에만 있음)
149
- modifiedLines.push(line.substring(1));
150
- } else if (line.startsWith(' ')) {
151
- // 변경 없는 라인
152
- if (lineNum < originalLines.length) {
153
- modifiedLines.push(originalLines[lineNum]);
154
- lineNum++;
155
- } else {
156
- modifiedLines.push(line.substring(1));
157
- }
158
- }
159
- }
160
-
161
- // 수정본이 비어있으면 원본을 기반으로 단순 패치 시도
162
- if (modifiedLines.length === 0) {
163
- modified = original;
164
- } else {
165
- modified = modifiedLines.join('\n');
166
- }
167
-
168
- return { original, modified };
169
- }
170
-
171
25
  // ==========================================
172
26
  // 폴더 리뷰 명령어
173
27
  // ==========================================
@@ -0,0 +1,91 @@
1
+ // lib/api-client.js
2
+ const axios = require('axios');
3
+
4
+ class CodeReviewClient {
5
+ constructor(serverUrl = 'http://211.56.247.71:8000') {
6
+ this.serverUrl = serverUrl;
7
+ this.client = axios.create({
8
+ baseURL: serverUrl,
9
+ timeout: 300000, // 5분 타임아웃 (모델 추론 시간 고려)
10
+ headers: {
11
+ 'Content-Type': 'application/json'
12
+ }
13
+ });
14
+ }
15
+
16
+ /**
17
+ * 서버 헬스 체크
18
+ */
19
+ async healthCheck() {
20
+ try {
21
+ const response = await this.client.get('/health');
22
+ return response.data;
23
+ } catch (error) {
24
+ this.handleError(error);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 폴더 전체 리뷰
30
+ * @param {string} folderPath - 리뷰할 폴더 경로
31
+ * @param {object} options - 옵션 (include_patterns, exclude_patterns, max_files)
32
+ */
33
+ async reviewFolder(folderPath, options = {}) {
34
+ try {
35
+ const response = await this.client.post('/review/folder', {
36
+ folder_path: folderPath,
37
+ include_patterns: options.includePatterns || ['*.py', '*.js', '*.ts', '*.java', '*.cpp', '*.go'],
38
+ exclude_patterns: options.excludePatterns || ['node_modules', '__pycache__', '.git', '*.pyc'],
39
+ max_files: options.maxFiles || 50
40
+ });
41
+ return response.data;
42
+ } catch (error) {
43
+ this.handleError(error);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 단일 코드 스니펫 리뷰
49
+ * @param {string} code - 리뷰할 코드
50
+ * @param {string} language - 프로그래밍 언어
51
+ * @param {string} checklist - 체크리스트 (선택)
52
+ */
53
+ async reviewCode(code, language = 'python', checklist = null) {
54
+ try {
55
+ const response = await this.client.post('/review/code', {
56
+ code,
57
+ language,
58
+ checklist
59
+ });
60
+ return response.data;
61
+ } catch (error) {
62
+ this.handleError(error);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 에러 핸들링
68
+ */
69
+ handleError(error) {
70
+ if (error.response) {
71
+ // 서버 응답이 있는 경우
72
+ const data = error.response.data;
73
+
74
+ // IP 차단 메시지
75
+ if (data.error === '허용된 아이피가 아닙니다') {
76
+ throw new Error(`❌ ${data.error}\n현재 IP: ${data.client_ip}\n허용된 IP: ${data.allowed_ips.join(', ')}`);
77
+ }
78
+
79
+ // 기타 서버 에러
80
+ throw new Error(`서버 에러 (${error.response.status}): ${data.detail || JSON.stringify(data)}`);
81
+ } else if (error.request) {
82
+ // 요청은 보냈지만 응답이 없는 경우
83
+ throw new Error(`서버 연결 실패: ${this.serverUrl}\n서버가 실행 중인지 확인하세요.`);
84
+ } else {
85
+ // 요청 설정 중 에러
86
+ throw new Error(`요청 실패: ${error.message}`);
87
+ }
88
+ }
89
+ }
90
+
91
+ module.exports = CodeReviewClient;
@@ -0,0 +1,245 @@
1
+ // lib/chat-mode.js
2
+ const readline = require('readline');
3
+ const chalk = require('chalk');
4
+ const FileReader = require('./file-reader');
5
+ const CodeReviewClient = require('./api-client');
6
+ const { parseDiffAndShowInVSCode } = require('./diff-viewer');
7
+
8
+ class ChatMode {
9
+ constructor() {
10
+ this.fileReader = new FileReader();
11
+ // 환경변수가 있으면 사용, 없으면 기본값 사용
12
+ const serverUrl = process.env.CODE_REVIEW_SERVER;
13
+ this.client = serverUrl ? new CodeReviewClient(serverUrl) : new CodeReviewClient();
14
+ this.projectFiles = [];
15
+ this.filesContent = {};
16
+ this.rl = null;
17
+ }
18
+
19
+ /**
20
+ * Chat 모드 시작
21
+ */
22
+ async start() {
23
+ console.log(chalk.bold.green('\n🛡️ 안녕하세요, 코드가드입니다!\n'));
24
+
25
+ // 프로젝트 파일 분석 여부 물어보기
26
+ const shouldAnalyze = await this.askQuestion('현재 위치의 폴더 구조를 파악할까요? (y/n): ');
27
+
28
+ if (shouldAnalyze.toLowerCase() === 'y' || shouldAnalyze.toLowerCase() === 'yes') {
29
+ await this.analyzeProject();
30
+ }
31
+
32
+ // REPL 시작
33
+ await this.startREPL();
34
+ }
35
+
36
+ /**
37
+ * 프로젝트 파일 분석
38
+ */
39
+ async analyzeProject() {
40
+ console.log(chalk.cyan('\n📂 폴더 구조 분석 중...\n'));
41
+
42
+ try {
43
+ this.projectFiles = await this.fileReader.listFiles();
44
+
45
+ if (this.projectFiles.length === 0) {
46
+ console.log(chalk.yellow('⚠️ 분석할 파일이 없습니다.'));
47
+ return;
48
+ }
49
+
50
+ // 폴더 트리 출력
51
+ console.log(chalk.bold('프로젝트 구조:'));
52
+ const tree = this.fileReader.generateTree(this.projectFiles);
53
+ console.log(tree);
54
+
55
+ console.log(chalk.green(`✅ 총 ${this.projectFiles.length}개 파일 발견\n`));
56
+ } catch (error) {
57
+ console.error(chalk.red(`❌ 파일 분석 실패: ${error.message}`));
58
+ }
59
+ }
60
+
61
+ /**
62
+ * REPL 모드 시작
63
+ */
64
+ async startREPL() {
65
+ this.rl = readline.createInterface({
66
+ input: process.stdin,
67
+ output: process.stdout,
68
+ prompt: chalk.bold.cyan('> ')
69
+ });
70
+
71
+ console.log(chalk.gray('무엇을 도와드릴까요? (종료하려면 "exit" 입력)\n'));
72
+ this.rl.prompt();
73
+
74
+ this.rl.on('line', async (input) => {
75
+ const command = input.trim();
76
+
77
+ if (!command) {
78
+ this.rl.prompt();
79
+ return;
80
+ }
81
+
82
+ if (command === 'exit' || command === 'quit') {
83
+ console.log(chalk.green('\n👋 안녕히 가세요!'));
84
+ this.rl.close();
85
+ process.exit(0);
86
+ }
87
+
88
+ await this.handleCommand(command);
89
+ this.rl.prompt();
90
+ });
91
+
92
+ this.rl.on('close', () => {
93
+ process.exit(0);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * 사용자 명령어 처리
99
+ */
100
+ async handleCommand(command) {
101
+ const lowerCommand = command.toLowerCase();
102
+
103
+ // 파일 리뷰 요청 감지
104
+ if (lowerCommand.includes('리뷰') || lowerCommand.includes('review') ||
105
+ lowerCommand.includes('검사') || lowerCommand.includes('check')) {
106
+ await this.handleReviewRequest(command);
107
+ }
108
+ // 파일 목록 요청
109
+ else if (lowerCommand.includes('목록') || lowerCommand.includes('list') ||
110
+ lowerCommand.includes('파일')) {
111
+ this.showFileList();
112
+ }
113
+ // 도움말
114
+ else if (lowerCommand.includes('help') || lowerCommand.includes('도움말')) {
115
+ this.showHelp();
116
+ }
117
+ // 기타 - 일반 질문으로 처리
118
+ else {
119
+ console.log(chalk.yellow('\n💡 파일을 리뷰하려면 "파일명 리뷰해줘" 형식으로 입력해주세요.'));
120
+ console.log(chalk.gray(' 예: api.py 리뷰해줘, 모든 Python 파일 검사해줘\n'));
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 리뷰 요청 처리
126
+ */
127
+ async handleReviewRequest(command) {
128
+ // 파일 매칭
129
+ const matchedFiles = this.fileReader.matchFiles(command, this.projectFiles);
130
+
131
+ if (matchedFiles.length === 0) {
132
+ console.log(chalk.yellow('\n⚠️ 매칭되는 파일을 찾을 수 없습니다.'));
133
+ console.log(chalk.gray(' 사용 가능한 파일 목록을 보려면 "파일 목록" 입력\n'));
134
+ return;
135
+ }
136
+
137
+ console.log(chalk.cyan(`\n🔍 ${matchedFiles.length}개 파일 리뷰 중...\n`));
138
+
139
+ for (const filePath of matchedFiles) {
140
+ await this.reviewFile(filePath);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 단일 파일 리뷰
146
+ */
147
+ async reviewFile(filePath) {
148
+ try {
149
+ console.log(chalk.bold(`\n=== ${filePath} ===\n`));
150
+
151
+ // 파일 내용 읽기
152
+ console.log(chalk.gray(`📖 파일 읽는 중...`));
153
+ const content = await this.fileReader.readFile(filePath);
154
+ console.log(chalk.gray(`✓ 파일 읽기 완료 (${content.length}자)`));
155
+
156
+ // 언어 감지
157
+ const language = this.detectLanguage(filePath);
158
+ console.log(chalk.gray(`🔍 언어: ${language}`));
159
+
160
+ // 서버에 리뷰 요청
161
+ console.log(chalk.gray(`🚀 서버로 리뷰 요청 중...`));
162
+ const result = await this.client.reviewCode(content, language);
163
+ const reveiwTextOnly =
164
+ parserDiffAndShowInVSCode(result.reivew, filePath);
165
+ console.log(reviewTextOnly);
166
+ console.log(chalk.gray(`✓ 리뷰 완료\n`));
167
+
168
+ // 결과 출력
169
+ console.log(result.review);
170
+ console.log('');
171
+
172
+ } catch (error) {
173
+ console.error(chalk.red(`❌ 리뷰 실패 (${filePath}): ${error.message}\n`));
174
+ }
175
+ }
176
+
177
+ /**
178
+ * 파일 목록 출력
179
+ */
180
+ showFileList() {
181
+ if (this.projectFiles.length === 0) {
182
+ console.log(chalk.yellow('\n⚠️ 분석된 파일이 없습니다. 먼저 프로젝트를 분석해주세요.\n'));
183
+ return;
184
+ }
185
+
186
+ console.log(chalk.bold('\n📁 사용 가능한 파일:\n'));
187
+ this.projectFiles.forEach(file => {
188
+ console.log(chalk.gray(` - ${file}`));
189
+ });
190
+ console.log('');
191
+ }
192
+
193
+ /**
194
+ * 도움말 출력
195
+ */
196
+ showHelp() {
197
+ console.log(chalk.bold('\n📖 사용 가능한 명령어:\n'));
198
+ console.log(chalk.cyan(' 파일명 리뷰해줘') + chalk.gray(' - 특정 파일 리뷰'));
199
+ console.log(chalk.cyan(' 모든 Python 파일 검사') + chalk.gray(' - 확장자별 리뷰'));
200
+ console.log(chalk.cyan(' 파일 목록') + chalk.gray(' - 분석 가능한 파일 목록'));
201
+ console.log(chalk.cyan(' help') + chalk.gray(' - 이 도움말'));
202
+ console.log(chalk.cyan(' exit') + chalk.gray(' - 종료\n'));
203
+ }
204
+
205
+ /**
206
+ * 파일 확장자로 언어 감지
207
+ */
208
+ detectLanguage(filePath) {
209
+ const ext = filePath.split('.').pop().toLowerCase();
210
+ const langMap = {
211
+ 'py': 'python',
212
+ 'js': 'javascript',
213
+ 'jsx': 'javascript',
214
+ 'ts': 'typescript',
215
+ 'tsx': 'typescript',
216
+ 'java': 'java',
217
+ 'cpp': 'cpp',
218
+ 'c': 'c',
219
+ 'go': 'go',
220
+ 'rs': 'rust',
221
+ 'rb': 'ruby',
222
+ 'php': 'php'
223
+ };
224
+ return langMap[ext] || 'text';
225
+ }
226
+
227
+ /**
228
+ * 사용자에게 질문하고 답변 받기
229
+ */
230
+ askQuestion(question) {
231
+ return new Promise((resolve) => {
232
+ const rl = readline.createInterface({
233
+ input: process.stdin,
234
+ output: process.stdout
235
+ });
236
+
237
+ rl.question(chalk.bold.cyan(question), (answer) => {
238
+ rl.close();
239
+ resolve(answer);
240
+ });
241
+ });
242
+ }
243
+ }
244
+
245
+ module.exports = ChatMode;
@@ -0,0 +1,14 @@
1
+ // lib/diff-viewer.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+
8
+ // ⚠️ 기존 bin/code-review.js에 있던
9
+ // parseDiffAndShowInVSCode + extractCodeFromDiff
10
+ // 그대로 복사해서 여기 붙여넣기
11
+
12
+ module.exports = {
13
+ parseDiffAndShowInVSCode,
14
+ };
@@ -0,0 +1,159 @@
1
+ // lib/file-reader.js
2
+ const fs = require('fs').promises;
3
+ const path = require('path');
4
+ const fg = require('fast-glob');
5
+
6
+ class FileReader {
7
+ constructor(basePath = process.cwd()) {
8
+ this.basePath = basePath;
9
+ }
10
+
11
+ /**
12
+ * 폴더 구조를 읽고 파일 목록 반환
13
+ * @param {object} options - { include, exclude, maxFiles }
14
+ * @returns {Promise<string[]>} 파일 경로 배열
15
+ */
16
+ async listFiles(options = {}) {
17
+ const {
18
+ include = ['**/*.{js,ts,py,java,cpp,go,jsx,tsx}'],
19
+ exclude = ['**/node_modules/**', '**/__pycache__/**', '**/.git/**', '**/*.pyc'],
20
+ maxFiles = 50
21
+ } = options;
22
+
23
+ const files = await fg(include, {
24
+ cwd: this.basePath,
25
+ ignore: exclude,
26
+ absolute: false,
27
+ onlyFiles: true
28
+ });
29
+
30
+ return files.slice(0, maxFiles);
31
+ }
32
+
33
+ /**
34
+ * 파일 내용 읽기
35
+ * @param {string} filePath - 상대 경로
36
+ * @returns {Promise<string>} 파일 내용
37
+ */
38
+ async readFile(filePath) {
39
+ const fullPath = path.join(this.basePath, filePath);
40
+ try {
41
+ return await fs.readFile(fullPath, 'utf-8');
42
+ } catch (error) {
43
+ throw new Error(`파일 읽기 실패 (${filePath}): ${error.message}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 여러 파일의 내용을 읽어서 객체로 반환
49
+ * @param {string[]} filePaths - 파일 경로 배열
50
+ * @returns {Promise<object>} { 파일경로: 내용 }
51
+ */
52
+ async readMultipleFiles(filePaths) {
53
+ const filesContent = {};
54
+
55
+ for (const filePath of filePaths) {
56
+ try {
57
+ filesContent[filePath] = await this.readFile(filePath);
58
+ } catch (error) {
59
+ console.warn(`⚠️ ${error.message}`);
60
+ }
61
+ }
62
+
63
+ return filesContent;
64
+ }
65
+
66
+ /**
67
+ * 폴더 구조 트리 생성 (시각화용)
68
+ * @param {string[]} filePaths - 파일 경로 배열
69
+ * @returns {string} 트리 형식 문자열
70
+ */
71
+ generateTree(filePaths) {
72
+ const tree = {};
73
+
74
+ filePaths.forEach(filePath => {
75
+ const parts = filePath.split(path.sep);
76
+ let current = tree;
77
+
78
+ parts.forEach((part, index) => {
79
+ if (index === parts.length - 1) {
80
+ // 파일
81
+ if (!current.__files) current.__files = [];
82
+ current.__files.push(part);
83
+ } else {
84
+ // 디렉토리
85
+ if (!current[part]) current[part] = {};
86
+ current = current[part];
87
+ }
88
+ });
89
+ });
90
+
91
+ return this._renderTree(tree, '', true);
92
+ }
93
+
94
+ _renderTree(node, prefix = '', isLast = true) {
95
+ let result = '';
96
+ const entries = Object.keys(node).filter(k => k !== '__files');
97
+ const files = node.__files || [];
98
+
99
+ // 디렉토리 먼저
100
+ entries.forEach((dir, index) => {
101
+ const isLastDir = index === entries.length - 1 && files.length === 0;
102
+ const connector = isLastDir ? '└── ' : '├── ';
103
+ const newPrefix = prefix + (isLastDir ? ' ' : '│ ');
104
+
105
+ result += `${prefix}${connector}${dir}/\n`;
106
+ result += this._renderTree(node[dir], newPrefix, isLastDir);
107
+ });
108
+
109
+ // 파일들
110
+ files.forEach((file, index) => {
111
+ const isLastFile = index === files.length - 1;
112
+ const connector = isLastFile ? '└── ' : '├── ';
113
+ result += `${prefix}${connector}${file}\n`;
114
+ });
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * 파일 경로에서 파일 찾기 (자연어 매칭)
121
+ * @param {string} query - "api.py", "모든 Python 파일"
122
+ * @param {string[]} availableFiles - 사용 가능한 파일 목록
123
+ * @returns {string[]} 매칭된 파일 경로들
124
+ */
125
+ matchFiles(query, availableFiles) {
126
+ const lowerQuery = query.toLowerCase();
127
+
128
+ // 정확한 파일명 매칭
129
+ const exactMatch = availableFiles.filter(f =>
130
+ path.basename(f).toLowerCase() === lowerQuery ||
131
+ f.toLowerCase().includes(lowerQuery)
132
+ );
133
+
134
+ if (exactMatch.length > 0) return exactMatch;
135
+
136
+ // 확장자 매칭 ("모든 Python 파일", "py 파일들")
137
+ const extMap = {
138
+ 'python': '.py',
139
+ 'javascript': '.js',
140
+ 'typescript': '.ts',
141
+ 'java': '.java',
142
+ 'cpp': '.cpp',
143
+ 'go': '.go'
144
+ };
145
+
146
+ for (const [lang, ext] of Object.entries(extMap)) {
147
+ if (lowerQuery.includes(lang) || lowerQuery.includes(ext.slice(1))) {
148
+ return availableFiles.filter(f => f.endsWith(ext));
149
+ }
150
+ }
151
+
152
+ // 키워드 매칭 ("api", "test", "model")
153
+ return availableFiles.filter(f =>
154
+ f.toLowerCase().includes(lowerQuery.replace(/\s+/g, ''))
155
+ );
156
+ }
157
+ }
158
+
159
+ module.exports = FileReader;
package/lib/chat-mode.js CHANGED
@@ -3,6 +3,7 @@ const readline = require('readline');
3
3
  const chalk = require('chalk');
4
4
  const FileReader = require('./file-reader');
5
5
  const CodeReviewClient = require('./api-client');
6
+ const { parseDiffAndShowInVSCode } = require('./diff-viewer');
6
7
 
7
8
  class ChatMode {
8
9
  constructor() {
@@ -159,6 +160,9 @@ class ChatMode {
159
160
  // 서버에 리뷰 요청
160
161
  console.log(chalk.gray(`🚀 서버로 리뷰 요청 중...`));
161
162
  const result = await this.client.reviewCode(content, language);
163
+ const reveiwTextOnly =
164
+ parserDiffAndShowInVSCode(result.reivew, filePath);
165
+ console.log(reviewTextOnly);
162
166
  console.log(chalk.gray(`✓ 리뷰 완료\n`));
163
167
 
164
168
  // 결과 출력
@@ -0,0 +1,167 @@
1
+ // lib/diff-viewer.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+
8
+ // Diff 파싱 및 VSCode 표시 함수
9
+ function parseDiffAndShowInVSCode(reviewText, originalFilePath) {
10
+ const lines = reviewText.split('\n');
11
+ const diffs = [];
12
+ let currentDiff = null;
13
+ let reviewContent = [];
14
+ let inDiffBlock = false;
15
+
16
+ // 디버깅: 전체 응답 확인
17
+ console.log(chalk.gray('\n[디버그] 응답 텍스트 일부:'));
18
+ console.log(chalk.gray(reviewText.substring(0, 500) + '...\n'));
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+
23
+ // unified diff 시작 감지 (--- 와 +++ 패턴)
24
+ // "--- 원본" 또는 "--- a/file" 형식도 지원
25
+ if ((line.startsWith('---') || line.includes('--- ')) &&
26
+ i + 1 < lines.length &&
27
+ (lines[i + 1].startsWith('+++') || lines[i + 1].includes('+++ '))) {
28
+ inDiffBlock = true;
29
+ currentDiff = {
30
+ filename: null,
31
+ diffLines: [line, lines[i + 1]]
32
+ };
33
+ console.log(chalk.yellow(`\n[디버그] diff 블록 발견: ${line}`));
34
+ i++; // +++ 라인 건너뛰기
35
+ continue;
36
+ }
37
+
38
+ // diff 블록 내부
39
+ if (inDiffBlock) {
40
+ if (line.startsWith('@@') || line.startsWith('-') || line.startsWith('+') || line.startsWith(' ')) {
41
+ currentDiff.diffLines.push(line);
42
+ } else if (line.trim() === '' || line.startsWith('===')) {
43
+ // diff 블록 종료
44
+ if (currentDiff) {
45
+ diffs.push(currentDiff);
46
+ console.log(chalk.yellow(`[디버그] diff 블록 종료 (라인 수: ${currentDiff.diffLines.length})`));
47
+ currentDiff = null;
48
+ }
49
+ inDiffBlock = false;
50
+ reviewContent.push(line);
51
+ } else {
52
+ currentDiff.diffLines.push(line);
53
+ }
54
+ } else {
55
+ // 일반 리뷰 텍스트
56
+ reviewContent.push(line);
57
+ }
58
+ }
59
+
60
+ // 마지막 diff 저장
61
+ if (currentDiff) {
62
+ diffs.push(currentDiff);
63
+ console.log(chalk.yellow(`[디버그] 마지막 diff 블록 저장 (라인 수: ${currentDiff.diffLines.length})`));
64
+ }
65
+
66
+ console.log(chalk.cyan(`\n[디버그] 총 ${diffs.length}개의 diff 블록 발견\n`));
67
+
68
+ // diff가 있으면 VSCode로 표시
69
+ if (diffs.length > 0) {
70
+ diffs.forEach((diff, index) => {
71
+ try {
72
+ // 임시 디렉토리 생성
73
+ const tmpDir = path.join(os.tmpdir(), `code-review-${Date.now()}-${index}`);
74
+ fs.mkdirSync(tmpDir, { recursive: true });
75
+
76
+ // 원본/수정본 파일 생성
77
+ const originalFile = path.join(tmpDir, 'original' + path.extname(originalFilePath));
78
+ const modifiedFile = path.join(tmpDir, 'modified' + path.extname(originalFilePath));
79
+
80
+ // diff에서 원본/수정본 코드 추출
81
+ const { original, modified } = extractCodeFromDiff(diff.diffLines, originalFilePath);
82
+
83
+ fs.writeFileSync(originalFile, original);
84
+ fs.writeFileSync(modifiedFile, modified);
85
+
86
+ // VSCode diff 뷰어 열기
87
+ try {
88
+ execSync(`code --diff "${originalFile}" "${modifiedFile}"`, { stdio: 'ignore' });
89
+ console.log(chalk.green(`\n📝 VSCode에서 diff를 열었습니다 (${index + 1}/${diffs.length})`));
90
+ } catch (e) {
91
+ console.log(chalk.yellow(`\n⚠️ VSCode를 열 수 없습니다. diff를 수동으로 확인하세요:`));
92
+ console.log(chalk.gray(diff.diffLines.join('\n')));
93
+ }
94
+
95
+ // 임시 파일은 5분 후 자동 삭제
96
+ setTimeout(() => {
97
+ try {
98
+ fs.unlinkSync(originalFile);
99
+ fs.unlinkSync(modifiedFile);
100
+ fs.rmdirSync(tmpDir);
101
+ } catch (e) {
102
+ // 무시
103
+ }
104
+ }, 5 * 60 * 1000);
105
+
106
+ } catch (error) {
107
+ console.error(chalk.red(`diff 처리 실패: ${error.message}`));
108
+ }
109
+ });
110
+ }
111
+
112
+ // 리뷰 텍스트만 반환
113
+ return reviewContent.join('\n').trim();
114
+ }
115
+
116
+ // diff에서 원본/수정본 추출
117
+ function extractCodeFromDiff(diffLines, originalFilePath) {
118
+ let original = '';
119
+ let modified = '';
120
+
121
+ // 원본 파일이 있으면 읽기
122
+ if (fs.existsSync(originalFilePath)) {
123
+ original = fs.readFileSync(originalFilePath, 'utf-8');
124
+ }
125
+
126
+ // diff 라인을 파싱해서 수정본 생성
127
+ const originalLines = original.split('\n');
128
+ const modifiedLines = [];
129
+
130
+ let lineNum = 0;
131
+ for (const line of diffLines) {
132
+ if (line.startsWith('@@')) {
133
+ // @@ -15,2 +15,2 @@ 형식 파싱
134
+ const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
135
+ if (match) {
136
+ lineNum = parseInt(match[1]) - 1;
137
+ }
138
+ } else if (line.startsWith('-')) {
139
+ // 삭제된 라인 (원본에만 있음)
140
+ lineNum++;
141
+ } else if (line.startsWith('+')) {
142
+ // 추가된 라인 (수정본에만 있음)
143
+ modifiedLines.push(line.substring(1));
144
+ } else if (line.startsWith(' ')) {
145
+ // 변경 없는 라인
146
+ if (lineNum < originalLines.length) {
147
+ modifiedLines.push(originalLines[lineNum]);
148
+ lineNum++;
149
+ } else {
150
+ modifiedLines.push(line.substring(1));
151
+ }
152
+ }
153
+ }
154
+
155
+ // 수정본이 비어있으면 원본을 기반으로 단순 패치 시도
156
+ if (modifiedLines.length === 0) {
157
+ modified = original;
158
+ } else {
159
+ modified = modifiedLines.join('\n');
160
+ }
161
+
162
+ return { original, modified };
163
+ }
164
+
165
+ module.exports = {
166
+ parseDiffAndShowInVSCode,
167
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cciwon-code-review-cli",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "AI-powered code review CLI tool using Qwen3-Coder-30B model with IP whitelist support",
5
5
  "main": "lib/api-client.js",
6
6
  "bin": {