ai-cmg 0.0.1 → 0.0.3

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 (3) hide show
  1. package/README.md +57 -0
  2. package/dist/index.js +342 -35
  3. package/package.json +4 -3
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # ai-cmg
2
+
3
+ An AI-powered CLI that analyzes staged changes and generates a commit message. You can commit immediately, edit in your editor, or copy the message.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g ai-cmg
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ ai-cmg
15
+ ```
16
+
17
+ Flow:
18
+ - If there are staged changes, it generates a message right away.
19
+ - If nothing is staged, it offers `git add .` or a file picker to stage selected files.
20
+ - You can choose to commit, edit, or copy the generated message.
21
+
22
+ ## Provide a Hint or Prompt
23
+
24
+ Use `-m` for a short hint (commit message guidance), and `-p` for a prompt (instruction).
25
+
26
+ ```bash
27
+ ai-cmg -m "Improve login error handling"
28
+ ai-cmg -p "Use a short title and 3 bullet points"
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ The Worker URL defaults to the built-in value but can be overridden.
34
+
35
+ ```bash
36
+ ai-cmg config --show
37
+ ai-cmg config --set https://your-worker.example.com
38
+ ai-cmg config --reset
39
+ ```
40
+
41
+ Interactive configuration is also available.
42
+
43
+ ```bash
44
+ ai-cmg config
45
+ ```
46
+
47
+ ## Version
48
+
49
+ ```bash
50
+ ai-cmg --version
51
+ ```
52
+
53
+ ## Notes
54
+
55
+ - The tool uses the **staged diff** to generate commit messages.
56
+ - Large diffs, binary/media files, and lockfiles are summarized to reduce token usage.
57
+
package/dist/index.js CHANGED
@@ -1,22 +1,314 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync, spawnSync } from 'child_process';
3
+ import { readFileSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
3
6
  import prompts from 'prompts';
4
- import pc from 'picocolors';
5
7
  import clipboardy from 'clipboardy';
6
8
  import Conf from 'conf';
7
- const WORKER_URL = 'https://commit.d-code.workers.dev';
9
+ const DEFAULT_WORKER_URL = 'https://commit.d-code.workers.dev';
8
10
  const config = new Conf({ projectName: 'ai-cmg' });
11
+ const MEDIA_EXTENSIONS = new Set([
12
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff',
13
+ '.mp4', '.mov', '.webm', '.mp3', '.wav', '.flac', '.ogg',
14
+ '.pdf', '.zip', '.tar', '.gz', '.7z', '.rar', '.woff', '.woff2', '.ttf', '.eot',
15
+ ]);
16
+ const LOCK_FILES = new Set([
17
+ 'package-lock.json',
18
+ 'npm-shrinkwrap.json',
19
+ 'yarn.lock',
20
+ 'pnpm-lock.yaml',
21
+ 'bun.lockb',
22
+ ]);
23
+ const GENERATED_PREFIXES = ['dist/', 'build/', '.next/', 'coverage/', 'node_modules/'];
24
+ const MAX_BLOCK_LINES = 400;
25
+ const MAX_BLOCK_CHARS = 20000;
26
+ function getPackageVersion() {
27
+ try {
28
+ const currentFile = fileURLToPath(import.meta.url);
29
+ const currentDir = dirname(currentFile);
30
+ const packageJsonPath = join(currentDir, '..', 'package.json');
31
+ const raw = readFileSync(packageJsonPath, 'utf8');
32
+ const parsed = JSON.parse(raw);
33
+ return parsed.version ?? null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ function getWorkerUrl() {
40
+ return config.get('workerUrl') ?? DEFAULT_WORKER_URL;
41
+ }
42
+ function showConfig() {
43
+ const workerUrl = getWorkerUrl();
44
+ console.log('Current configuration:');
45
+ console.log(`- workerUrl: ${workerUrl}`);
46
+ }
47
+ function showConfigHelp() {
48
+ console.log('Usage: ai-cmg config [--show | --set <url> | --reset]');
49
+ console.log(' --show Show current configuration');
50
+ console.log(' --set <url> Set Worker URL');
51
+ console.log(' --reset Reset Worker URL to default');
52
+ }
53
+ function setWorkerUrl(url) {
54
+ config.set('workerUrl', url);
55
+ console.log('Updated workerUrl.');
56
+ }
57
+ function resetWorkerUrl() {
58
+ config.delete('workerUrl');
59
+ console.log('Reset workerUrl to default.');
60
+ }
61
+ async function runConfigCommand(args) {
62
+ if (args.includes('--help') || args.includes('-h')) {
63
+ showConfigHelp();
64
+ return;
65
+ }
66
+ if (args.includes('--show')) {
67
+ showConfig();
68
+ return;
69
+ }
70
+ const setIndex = args.indexOf('--set');
71
+ if (setIndex >= 0) {
72
+ const nextUrl = args[setIndex + 1]?.trim();
73
+ if (!nextUrl) {
74
+ console.log('Error: --set requires a URL.');
75
+ return;
76
+ }
77
+ setWorkerUrl(nextUrl);
78
+ return;
79
+ }
80
+ if (args.includes('--reset')) {
81
+ resetWorkerUrl();
82
+ return;
83
+ }
84
+ showConfig();
85
+ const response = await prompts({
86
+ type: 'text',
87
+ name: 'workerUrl',
88
+ message: 'Enter Worker URL (leave empty to keep current):'
89
+ });
90
+ const nextUrl = response.workerUrl?.trim();
91
+ if (nextUrl) {
92
+ setWorkerUrl(nextUrl);
93
+ }
94
+ else {
95
+ console.log('No changes made.');
96
+ }
97
+ }
98
+ function parseMessageArgs(rawArgs) {
99
+ const hintParts = [];
100
+ let hint;
101
+ let prompt;
102
+ for (let i = 0; i < rawArgs.length; i += 1) {
103
+ const arg = rawArgs[i];
104
+ if (arg === '-m') {
105
+ const value = rawArgs[i + 1];
106
+ if (!value) {
107
+ console.log('Error: -m requires a value.');
108
+ return null;
109
+ }
110
+ hint = value;
111
+ i += 1;
112
+ continue;
113
+ }
114
+ if (arg === '-p') {
115
+ const value = rawArgs[i + 1];
116
+ if (!value) {
117
+ console.log('Error: -p requires a value.');
118
+ return null;
119
+ }
120
+ prompt = value;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ hintParts.push(arg);
125
+ }
126
+ if (!hint && hintParts.length > 0) {
127
+ hint = hintParts.join(' ');
128
+ }
129
+ return { hint, prompt };
130
+ }
131
+ function getNameStatus() {
132
+ const map = new Map();
133
+ try {
134
+ const output = execSync('git diff --cached --name-status').toString();
135
+ output.split('\n').forEach((line) => {
136
+ if (!line.trim())
137
+ return;
138
+ const [status, ...rest] = line.split('\t');
139
+ const filePath = rest[rest.length - 1];
140
+ if (filePath)
141
+ map.set(filePath, status);
142
+ });
143
+ }
144
+ catch {
145
+ // ignore status errors; diff will still be used
146
+ }
147
+ return map;
148
+ }
149
+ function getChangedFiles() {
150
+ try {
151
+ const output = execSync('git status --porcelain -z').toString();
152
+ if (!output)
153
+ return [];
154
+ const parts = output.split('\0').filter(Boolean);
155
+ const entries = [];
156
+ for (let i = 0; i < parts.length; i += 1) {
157
+ const entry = parts[i];
158
+ const status = entry.slice(0, 2);
159
+ let path = entry.slice(3);
160
+ if (status.startsWith('R') || status.startsWith('C')) {
161
+ const newPath = parts[i + 1];
162
+ if (newPath) {
163
+ path = newPath;
164
+ i += 1;
165
+ }
166
+ }
167
+ if (path)
168
+ entries.push({ path, status: status.trim() });
169
+ }
170
+ const unique = new Map();
171
+ entries.forEach((entry) => unique.set(entry.path, entry));
172
+ return Array.from(unique.values());
173
+ }
174
+ catch {
175
+ return [];
176
+ }
177
+ }
178
+ async function promptStageFiles() {
179
+ const candidates = getChangedFiles();
180
+ if (candidates.length === 0) {
181
+ console.log('No local changes to stage.');
182
+ return false;
183
+ }
184
+ console.log('No staged changes detected.');
185
+ console.log('Choose how to stage changes:');
186
+ const options = [
187
+ { label: 'Stage all files (git add .)', value: 'all' },
188
+ { label: 'Stage selected files', value: 'select' },
189
+ { label: 'Cancel', value: 'cancel' }
190
+ ];
191
+ options.forEach((option, index) => {
192
+ console.log(`${index + 1}. ${option.label}`);
193
+ });
194
+ const selection = await prompts({
195
+ type: 'number',
196
+ name: 'choice',
197
+ message: 'Enter a number (1-3):',
198
+ initial: 1,
199
+ min: 1,
200
+ max: options.length
201
+ });
202
+ const choice = selection.choice;
203
+ if (!choice || choice < 1 || choice > options.length)
204
+ return false;
205
+ const action = options[choice - 1].value;
206
+ if (action === 'cancel')
207
+ return false;
208
+ if (action === 'all') {
209
+ spawnSync('git', ['add', '.'], { stdio: 'inherit' });
210
+ return true;
211
+ }
212
+ const fileChoices = candidates.map((entry) => ({
213
+ title: `${entry.status.padEnd(2, ' ')} ${entry.path}`,
214
+ value: entry.path
215
+ }));
216
+ const picked = await prompts({
217
+ type: 'multiselect',
218
+ name: 'files',
219
+ message: 'Select files to stage (space to toggle, enter to submit):',
220
+ choices: fileChoices,
221
+ min: 1
222
+ });
223
+ const files = picked.files;
224
+ if (!files || files.length === 0)
225
+ return false;
226
+ spawnSync('git', ['add', '--', ...files], { stdio: 'inherit' });
227
+ return true;
228
+ }
229
+ function getExtension(filePath) {
230
+ const lower = filePath.toLowerCase();
231
+ const idx = lower.lastIndexOf('.');
232
+ return idx >= 0 ? lower.slice(idx) : '';
233
+ }
234
+ function shouldOmitFile(path, block) {
235
+ const lowerPath = path.toLowerCase();
236
+ if (LOCK_FILES.has(lowerPath))
237
+ return { omit: true, reason: 'lockfile' };
238
+ if (MEDIA_EXTENSIONS.has(getExtension(lowerPath)))
239
+ return { omit: true, reason: 'media/binary asset' };
240
+ if (GENERATED_PREFIXES.some((prefix) => lowerPath.startsWith(prefix))) {
241
+ return { omit: true, reason: 'generated artifact' };
242
+ }
243
+ if (block.includes('GIT binary patch') || block.includes('Binary files')) {
244
+ return { omit: true, reason: 'binary diff' };
245
+ }
246
+ const lines = block.split('\n').length;
247
+ if (lines > MAX_BLOCK_LINES || block.length > MAX_BLOCK_CHARS) {
248
+ return { omit: true, reason: 'large diff' };
249
+ }
250
+ return { omit: false };
251
+ }
252
+ function summarizeDiff(diff) {
253
+ const statusMap = getNameStatus();
254
+ const blocks = diff.split(/^diff --git /m);
255
+ const keptBlocks = [];
256
+ const omitted = [];
257
+ for (const block of blocks) {
258
+ if (!block.trim())
259
+ continue;
260
+ const headerLine = block.split('\n')[0] ?? '';
261
+ const match = headerLine.match(/^a\/(.+?) b\/(.+?)$/);
262
+ if (!match) {
263
+ keptBlocks.push(`diff --git ${block}`);
264
+ continue;
265
+ }
266
+ const aPath = match[1];
267
+ const bPath = match[2];
268
+ const filePath = bPath === '/dev/null' ? aPath : bPath;
269
+ const status = statusMap.get(filePath);
270
+ const originalBlock = `diff --git ${block}`;
271
+ const { omit, reason } = shouldOmitFile(filePath, originalBlock);
272
+ if (omit && reason) {
273
+ omitted.push({ path: filePath, reason, status });
274
+ keptBlocks.push(`diff --git a/${aPath} b/${bPath}\n# content omitted (${reason})\n`);
275
+ continue;
276
+ }
277
+ keptBlocks.push(originalBlock);
278
+ }
279
+ const summaryLines = omitted.map((item) => {
280
+ const statusLabel = item.status ? `${item.status} ` : '';
281
+ return `- ${statusLabel}${item.path} (${item.reason})`;
282
+ });
283
+ const summaryHeader = summaryLines.length
284
+ ? `# Omitted file contents (summarized)\n${summaryLines.join('\n')}\n\n`
285
+ : '';
286
+ return `${summaryHeader}${keptBlocks.join('\n')}`.trim();
287
+ }
9
288
  async function main() {
10
289
  // 1. 사용자 힌트(Arguments) 가져오기
11
- const args = process.argv.slice(2).join(' ');
12
- if (args) {
13
- console.log(pc.cyan(`💡 사용자 힌트 감지: "${args}"`));
290
+ const rawArgs = process.argv.slice(2);
291
+ if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
292
+ const version = getPackageVersion();
293
+ console.log(version ?? 'unknown');
294
+ return;
295
+ }
296
+ if (rawArgs[0] === 'config') {
297
+ await runConfigCommand(rawArgs.slice(1));
298
+ return;
299
+ }
300
+ const parsed = parseMessageArgs(rawArgs.filter((arg) => arg !== '--version' && arg !== '-v'));
301
+ if (!parsed)
302
+ return;
303
+ const { hint, prompt } = parsed;
304
+ if (hint) {
305
+ console.log(`Hint detected: "${hint}"`);
14
306
  }
15
307
  // 2. 인증 토큰 확인 (없으면 물어봄)
16
308
  let authToken = config.get('authToken');
17
309
  if (!authToken) {
18
- console.log(pc.bgMagenta(pc.white(' 🔐 최초 인증 설정 ')));
19
- console.log(pc.gray('팀에서 공유된 Access Token을 입력해주세요. (내 컴퓨터에 안전하게 저장됩니다)'));
310
+ console.log('Initial authentication setup');
311
+ console.log('Enter the shared access token. It will be stored locally.');
20
312
  const response = await prompts({
21
313
  type: 'password',
22
314
  name: 'token',
@@ -27,11 +319,11 @@ async function main() {
27
319
  return;
28
320
  authToken = response.token;
29
321
  config.set('authToken', authToken);
30
- console.log(pc.green(' 인증 정보가 저장되었습니다.\n'));
322
+ console.log('Token saved.\n');
31
323
  }
32
324
  if (!authToken)
33
325
  return;
34
- console.log(pc.cyan('ℹ️ Staged 변경 사항 분석 중...'));
326
+ console.log('Analyzing staged changes...');
35
327
  try {
36
328
  // 3. Git Diff 가져오기
37
329
  let diff;
@@ -39,26 +331,33 @@ async function main() {
39
331
  diff = execSync('git diff --cached').toString();
40
332
  }
41
333
  catch (e) {
42
- console.log(pc.red(' Git 저장소가 아니거나 git 명령어를 찾을 없습니다.'));
334
+ console.log('Error: not a git repository or git is unavailable.');
43
335
  return;
44
336
  }
45
337
  if (!diff.trim()) {
46
- console.log(pc.yellow('⚠️ 커밋할 변경 사항이 없습니다. "git add"를 먼저 실행해주세요.'));
47
- return;
338
+ const staged = await promptStageFiles();
339
+ if (!staged)
340
+ return;
341
+ diff = execSync('git diff --cached').toString();
342
+ if (!diff.trim()) {
343
+ console.log('No staged changes. Nothing to commit.');
344
+ return;
345
+ }
48
346
  }
347
+ const filteredDiff = summarizeDiff(diff);
49
348
  // 4. API 요청 (Diff + Hint + Auth)
50
- const response = await fetch(WORKER_URL, {
349
+ const response = await fetch(getWorkerUrl(), {
51
350
  method: 'POST',
52
351
  headers: {
53
352
  'Content-Type': 'application/json',
54
353
  'X-AUTH-TOKEN': authToken
55
354
  },
56
- body: JSON.stringify({ diff, hint: args }) // 힌트도 같이 전송
355
+ body: JSON.stringify({ diff: filteredDiff, hint, prompt }) // 힌트/프롬프트도 같이 전송
57
356
  });
58
357
  // 인증 실패 처리 (401)
59
358
  if (response.status === 401) {
60
- console.log(pc.red('\n⛔️ 인증 실패! 토큰이 만료되었거나 틀렸습니다.'));
61
- console.log(pc.gray('기존 설정 파일을 삭제합니다. 다시 실행하여 토큰을 입력해주세요.'));
359
+ console.log('\nAuthentication failed. The token is invalid or expired.');
360
+ console.log('Stored token will be cleared. Run again to enter a new token.');
62
361
  config.delete('authToken');
63
362
  return;
64
363
  }
@@ -67,44 +366,52 @@ async function main() {
67
366
  }
68
367
  const { message } = await response.json();
69
368
  // 5. 결과 출력 및 액션 선택
70
- console.log('\n' + pc.bgGreen(pc.black(' 🤖 AI 생성 커밋 메시지 ')) + '\n');
71
- console.log(pc.bold(message));
72
- console.log('\n' + pc.dim('-----------------------------------') + '\n');
369
+ console.log('\nCommit message:\n');
370
+ console.log(message);
371
+ console.log('\n-----------------------------------\n');
372
+ const actions = [
373
+ { label: 'Commit with this message', value: 'commit' },
374
+ { label: 'Edit in editor and commit', value: 'edit' },
375
+ { label: 'Copy to clipboard', value: 'copy' },
376
+ { label: 'Cancel', value: 'cancel' }
377
+ ];
378
+ console.log('Select an action:');
379
+ actions.forEach((action, index) => {
380
+ console.log(`${index + 1}. ${action.label}`);
381
+ });
73
382
  const result = await prompts({
74
- type: 'select',
75
- name: 'action',
76
- message: '어떻게 하시겠습니까?',
77
- choices: [
78
- { title: '🚀 이대로 커밋하기', value: 'commit' },
79
- { title: '✏️ 에디터에서 수정 후 커밋', value: 'edit' },
80
- { title: '📋 클립보드에 복사', value: 'copy' },
81
- { title: '❌ 취소', value: 'cancel' }
82
- ]
383
+ type: 'number',
384
+ name: 'choice',
385
+ message: 'Enter a number (1-4):',
386
+ initial: 1,
387
+ min: 1,
388
+ max: actions.length
83
389
  });
84
- const action = result.action;
85
- if (!action)
390
+ const choice = result.choice;
391
+ if (!choice || choice < 1 || choice > actions.length)
86
392
  return;
393
+ const action = actions[choice - 1].value;
87
394
  switch (action) {
88
395
  case 'commit':
89
396
  spawnSync('git', ['commit', '-m', message], { stdio: 'inherit' });
90
- console.log(pc.green(' 커밋 완료!'));
397
+ console.log('Commit complete.');
91
398
  break;
92
399
  case 'edit':
93
400
  spawnSync('git', ['commit', '-e', '-m', message], { stdio: 'inherit' });
94
- console.log(pc.green(' 커밋 완료!'));
401
+ console.log('Commit complete.');
95
402
  break;
96
403
  case 'copy':
97
404
  clipboardy.writeSync(message);
98
- console.log(pc.green(' 복사 완료!'));
405
+ console.log('Copied to clipboard.');
99
406
  break;
100
407
  case 'cancel':
101
- console.log(pc.gray('취소됨'));
408
+ console.log('Cancelled.');
102
409
  break;
103
410
  }
104
411
  }
105
412
  catch (error) {
106
413
  const message = error instanceof Error ? error.message : String(error);
107
- console.error(pc.red('오류 발생:'), message);
414
+ console.error('Error:', message);
108
415
  }
109
416
  }
110
417
  main();
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "ai-cmg",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AI Commit Message Generator",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ai-commit": "dist/index.js"
7
+ "ai-cmg": "dist/index.js",
8
+ "ai-commit": "dist/index.js",
9
+ "d-commit": "dist/index.js"
8
10
  },
9
11
  "main": "./dist/index.js",
10
12
  "types": "./dist/index.d.ts",
@@ -20,7 +22,6 @@
20
22
  "dependencies": {
21
23
  "clipboardy": "^5.0.2",
22
24
  "conf": "^15.0.2",
23
- "picocolors": "^1.1.1",
24
25
  "prompts": "^2.4.2"
25
26
  },
26
27
  "devDependencies": {