commit-ai-agent 1.0.8 → 2.0.0

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/public/app.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /* global marked */
2
2
 
3
3
  // ── State ──
4
- let selectedProject = null;
4
+ let selectedProject = "__self__";
5
5
  let isAnalyzing = false;
6
- let analyzeMode = "commit"; // 'commit' | 'status'
7
- let isSingleProject = false;
8
- let singleProjectName = "";
6
+ let analyzeMode = "commit";
7
+ let projectName = "";
9
8
 
10
9
  // ── Aria State Machine ──
11
10
  function setAriaState(state, opts = {}) {
@@ -37,7 +36,7 @@ function setAriaState(state, opts = {}) {
37
36
  // Bubble messages
38
37
  const p = opts.project ? `<strong>${opts.project}</strong>` : "";
39
38
  const msgMap = {
40
- idle: "어떤 프로젝트의 커밋을 분석해드릴까요?",
39
+ idle: "분석을 시작할까요?",
41
40
  "ready-commit": `${p} 최근 커밋을 확인했어요. 분석을 시작할까요? 👀`,
42
41
  "ready-status":
43
42
  opts.n > 0
@@ -71,17 +70,15 @@ async function init() {
71
70
  setupModeToggle();
72
71
  await checkConfig();
73
72
 
74
- if (isSingleProject) {
75
- await enterSingleProjectMode();
73
+ if (analyzeMode === "commit") {
74
+ await fetchCommitPreview();
76
75
  } else {
77
- await loadProjects();
78
- setAriaState("idle");
76
+ await fetchStatusPreview();
79
77
  }
80
78
 
81
- // wire static event listeners (elements guaranteed to exist now)
82
- document
83
- .getElementById("refresh-projects")
84
- .addEventListener("click", loadProjects);
79
+ // SSE: post-commit 자동 분석 이벤트 수신
80
+ connectAutoAnalysisEvents();
81
+
85
82
  document
86
83
  .getElementById("analyze-btn")
87
84
  .addEventListener("click", onAnalyzeClick);
@@ -97,6 +94,9 @@ async function init() {
97
94
  .addEventListener("click", () => {
98
95
  togglePre("status-diff-content", "status-diff-toggle-btn");
99
96
  });
97
+ document
98
+ .getElementById("refresh-hooks")
99
+ .addEventListener("click", loadHookStatus);
100
100
  }
101
101
 
102
102
  // ── Config check ──
@@ -107,106 +107,12 @@ async function checkConfig() {
107
107
  if (!data.hasKey) {
108
108
  document.getElementById("api-key-warn").style.display = "flex";
109
109
  }
110
- if (data.isSingleProject) {
111
- isSingleProject = true;
112
- singleProjectName = data.singleProjectName || "project";
113
- }
110
+ projectName = data.projectName || "project";
114
111
  } catch {}
115
112
  }
116
113
 
117
- // ── Single Project Mode ──
118
- async function enterSingleProjectMode() {
119
- // 프로젝트 선택 UI만 숨김 (모드 토글은 유지)
120
- const header = document.querySelector(".selector-card .card-header");
121
- const projectGrid = document.getElementById("project-grid");
122
- const selectedHint = document.getElementById("selected-hint");
123
- if (header) header.style.display = "none";
124
- if (projectGrid) projectGrid.style.display = "none";
125
- if (selectedHint) selectedHint.style.display = "none";
126
-
127
- // 현재 디렉토리를 프로젝트로 자동 선택
128
- selectedProject = "__self__";
129
-
130
- document.getElementById("analyze-btn").disabled = false;
131
- const btnText = document.getElementById("analyze-btn-text");
132
- if (btnText) btnText.textContent = "Hanni에게 분석 요청";
133
-
134
- // 현재 모드에 따라 미리 로드
135
- if (analyzeMode === "commit") {
136
- await fetchCommitPreview();
137
- } else {
138
- await fetchStatusPreview();
139
- }
140
- }
141
-
142
- // ── Projects ──
143
- async function loadProjects() {
144
- const grid = document.getElementById("project-grid");
145
- grid.innerHTML =
146
- '<div class="skeleton-grid"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>';
147
- try {
148
- const res = await fetch("/api/projects");
149
- const { projects } = await res.json();
150
- renderProjects(projects);
151
- } catch (err) {
152
- grid.innerHTML = `<p style="color:var(--danger);font-size:14px">프로젝트 목록을 불러오지 못했습니다: ${err.message}</p>`;
153
- }
154
- }
155
-
156
- function renderProjects(projects) {
157
- const grid = document.getElementById("project-grid");
158
- if (!projects || projects.length === 0) {
159
- grid.innerHTML =
160
- '<p style="color:var(--text3);font-size:14px">git 프로젝트가 없습니다.</p>';
161
- return;
162
- }
163
- grid.innerHTML = projects
164
- .map(
165
- (p) => `
166
- <div class="project-item" data-name="${p.name}">
167
- <span class="proj-icon">${getProjectIcon(p.name)}</span>
168
- <span class="proj-name">${p.name}</span>
169
- </div>
170
- `,
171
- )
172
- .join("");
173
- grid.querySelectorAll(".project-item").forEach((el) => {
174
- el.addEventListener("click", () => selectProject(el));
175
- });
176
- }
177
-
178
- function getProjectIcon(name) {
179
- if (name.includes("next") || name.includes("react")) return "⚛️";
180
- if (name.includes("nest") || name.includes("api")) return "🐉";
181
- if (name.includes("hook")) return "🪝";
182
- if (name.includes("portfolio")) return "🎨";
183
- if (name.includes("todo")) return "✅";
184
- if (name.includes("doc")) return "📚";
185
- return "📁";
186
- }
187
-
188
- // ── Project Selection ──
189
- async function selectProject(el) {
190
- document
191
- .querySelectorAll(".project-item")
192
- .forEach((e) => e.classList.remove("selected"));
193
- el.classList.add("selected");
194
- selectedProject = el.dataset.name;
195
- document.getElementById("selected-hint").textContent =
196
- `선택됨: ${selectedProject}`;
197
- document.getElementById("analyze-btn").disabled = false;
198
- document.getElementById("commit-card").style.display = "none";
199
- document.getElementById("status-card").style.display = "none";
200
-
201
- if (analyzeMode === "commit") {
202
- await fetchCommitPreview();
203
- } else {
204
- await fetchStatusPreview();
205
- }
206
- }
207
-
114
+ // ── Preview loading ──
208
115
  async function fetchCommitPreview() {
209
- const displayName = isSingleProject ? singleProjectName : selectedProject;
210
116
  try {
211
117
  const res = await fetch(
212
118
  `/api/projects/${encodeURIComponent(selectedProject)}/commit`,
@@ -215,15 +121,14 @@ async function fetchCommitPreview() {
215
121
  if (error) throw new Error(error);
216
122
  renderCommitCard(commit);
217
123
  document.getElementById("commit-card").style.display = "block";
218
- setAriaState("ready-commit", { project: displayName });
124
+ setAriaState("ready-commit", { project: projectName });
219
125
  } catch (e) {
220
126
  console.warn("commit preview failed:", e.message);
221
- setAriaState("ready-commit", { project: displayName });
127
+ setAriaState("ready-commit", { project: projectName });
222
128
  }
223
129
  }
224
130
 
225
131
  async function fetchStatusPreview() {
226
- const displayName = isSingleProject ? singleProjectName : selectedProject;
227
132
  try {
228
133
  const res = await fetch(
229
134
  `/api/projects/${encodeURIComponent(selectedProject)}/status`,
@@ -234,17 +139,15 @@ async function fetchStatusPreview() {
234
139
  renderStatusCard(status);
235
140
  document.getElementById("status-card").style.display = "block";
236
141
  setAriaState("ready-status", {
237
- project: displayName,
142
+ project: projectName,
238
143
  n: status.totalFiles,
239
144
  });
240
145
  } else {
241
- const hint = document.getElementById("selected-hint");
242
- if (hint) hint.textContent = `${displayName} — 변경사항 없음`;
243
- setAriaState("ready-status", { project: displayName, n: 0 });
146
+ setAriaState("ready-status", { project: projectName, n: 0 });
244
147
  }
245
148
  } catch (e) {
246
149
  console.warn("status preview failed:", e.message);
247
- setAriaState("ready-status", { project: displayName, n: 0 });
150
+ setAriaState("ready-status", { project: projectName, n: 0 });
248
151
  }
249
152
  }
250
153
 
@@ -318,7 +221,6 @@ function switchMode(mode) {
318
221
  btnText.textContent =
319
222
  mode === "commit" ? "Hanni에게 분석 요청" : "Hanni에게 리뷰 요청";
320
223
 
321
- if (!selectedProject) return;
322
224
  if (mode === "commit") fetchCommitPreview();
323
225
  else fetchStatusPreview();
324
226
  }
@@ -331,7 +233,7 @@ function onAnalyzeClick() {
331
233
 
332
234
  // ── Generic SSE Analysis ──
333
235
  async function startAnalysis(endpoint) {
334
- if (isAnalyzing || !selectedProject) return;
236
+ if (isAnalyzing) return;
335
237
  isAnalyzing = true;
336
238
 
337
239
  const resultCard = document.getElementById("result-card");
@@ -343,7 +245,7 @@ async function startAnalysis(endpoint) {
343
245
  resultCard.style.display = "block";
344
246
  analysisBody.innerHTML = "";
345
247
  reportSaved.textContent = "";
346
- document.getElementById("copy-btn").style.display = "none"; // 분석 시작 시 숨김
248
+ document.getElementById("copy-btn").style.display = "none";
347
249
  setStatus("loading", "Hanni가 코드를 살펴보고 있어요...");
348
250
  setAriaState("thinking");
349
251
  analyzeBtn.disabled = true;
@@ -398,7 +300,7 @@ async function startAnalysis(endpoint) {
398
300
  } else if (data.type === "done") {
399
301
  setStatus("done", "✅ 분석 완료!");
400
302
  setAriaState("done");
401
- document.getElementById("copy-btn").style.display = "inline-flex"; // 완료 시에만 표시
303
+ document.getElementById("copy-btn").style.display = "inline-flex";
402
304
  } else if (data.type === "error") {
403
305
  setStatus("error", `오류: ${data.message}`);
404
306
  setAriaState("error");
@@ -440,6 +342,99 @@ function onCopy() {
440
342
  });
441
343
  }
442
344
 
345
+ // ── Auto Analysis via SSE (+ polling fallback) ──
346
+ let autoAnalysisPollTimer = null;
347
+ let autoAnalysisShownFilename = null;
348
+
349
+ function connectAutoAnalysisEvents() {
350
+ const evtSource = new EventSource("/api/events");
351
+
352
+ evtSource.onmessage = (e) => {
353
+ try {
354
+ const data = JSON.parse(e.data);
355
+ handleAutoAnalysisEvent(data);
356
+ } catch {}
357
+ };
358
+
359
+ evtSource.onerror = () => {
360
+ // EventSource 자동 재연결됨 — 별도 처리 불필요
361
+ };
362
+
363
+ // SSE와 관계없이 5초마다 상태 폴링 (SSE 놓쳐도 반드시 동작)
364
+ startAutoAnalysisPoll();
365
+ }
366
+
367
+ function handleAutoAnalysisEvent(data) {
368
+ if (isAnalyzing) return;
369
+ if (data.type === "analysis-started") {
370
+ showAutoAnalysisStarted(data.projectName);
371
+ startAutoAnalysisPoll();
372
+ } else if (data.type === "analysis-done") {
373
+ stopAutoAnalysisPoll();
374
+ if (data.filename !== autoAnalysisShownFilename) {
375
+ autoAnalysisShownFilename = data.filename;
376
+ showAutoAnalysisDone(data);
377
+ }
378
+ } else if (data.type === "analysis-error") {
379
+ stopAutoAnalysisPoll();
380
+ setStatus("error", `자동 분석 오류: ${data.message}`);
381
+ setAriaState("error");
382
+ }
383
+ }
384
+
385
+ function startAutoAnalysisPoll() {
386
+ stopAutoAnalysisPoll();
387
+ autoAnalysisPollTimer = setInterval(async () => {
388
+ if (isAnalyzing) return;
389
+ try {
390
+ const res = await fetch("/api/auto-analysis/state");
391
+ const state = await res.json();
392
+ if (state.status === "analyzing" && autoAnalysisShownFilename !== "__loading__") {
393
+ autoAnalysisShownFilename = "__loading__";
394
+ showAutoAnalysisStarted(state.projectName);
395
+ } else if (state.status === "done" && state.filename !== autoAnalysisShownFilename) {
396
+ autoAnalysisShownFilename = state.filename;
397
+ showAutoAnalysisDone(state);
398
+ }
399
+ } catch {}
400
+ }, 5000);
401
+ }
402
+
403
+ function stopAutoAnalysisPoll() {
404
+ if (autoAnalysisPollTimer) {
405
+ clearInterval(autoAnalysisPollTimer);
406
+ autoAnalysisPollTimer = null;
407
+ }
408
+ }
409
+
410
+ function showAutoAnalysisStarted(pName) {
411
+ const resultCard = document.getElementById("result-card");
412
+ const analysisBody = document.getElementById("analysis-body");
413
+ const reportSaved = document.getElementById("report-saved");
414
+ resultCard.style.display = "block";
415
+ analysisBody.innerHTML = "";
416
+ reportSaved.textContent = "";
417
+ document.getElementById("copy-btn").style.display = "none";
418
+ setStatus("loading", `${pName} 커밋 자동 분석 중...`);
419
+ setAriaState("thinking");
420
+ resultCard.scrollIntoView({ behavior: "smooth", block: "start" });
421
+ }
422
+
423
+ function showAutoAnalysisDone({ filename, content }) {
424
+ const resultCard = document.getElementById("result-card");
425
+ const analysisBody = document.getElementById("analysis-body");
426
+ const reportSaved = document.getElementById("report-saved");
427
+ const copyBtn = document.getElementById("copy-btn");
428
+ resultCard.style.display = "block";
429
+ analysisBody.innerHTML = marked.parse(content);
430
+ reportSaved.textContent = `✓ 저장됨: ${filename}`;
431
+ setStatus("done", "✅ 자동 분석 완료!");
432
+ setAriaState("done");
433
+ copyBtn.style.display = "inline-flex";
434
+ copyBtn._text = content;
435
+ resultCard.scrollIntoView({ behavior: "smooth", block: "start" });
436
+ }
437
+
443
438
  // ── Reports Tab ──
444
439
  async function loadReports() {
445
440
  const listEl = document.getElementById("reports-list");
@@ -506,10 +501,85 @@ function setupTabs() {
506
501
  btn.classList.add("active");
507
502
  document.getElementById("tab-" + tab).classList.add("active");
508
503
  if (tab === "reports") loadReports();
504
+ if (tab === "hooks") loadHookStatus();
509
505
  });
510
506
  });
511
507
  }
512
508
 
509
+ // ── Hooks Tab ──
510
+ async function loadHookStatus() {
511
+ const listEl = document.getElementById("hook-projects-list");
512
+ listEl.innerHTML = '<p class="empty-state">불러오는 중...</p>';
513
+ try {
514
+ const res = await fetch("/api/hooks/status");
515
+ const { projects } = await res.json();
516
+ if (!projects || projects.length === 0) {
517
+ listEl.innerHTML =
518
+ '<p class="empty-state">git 프로젝트를 찾을 수 없습니다.</p>';
519
+ return;
520
+ }
521
+ listEl.innerHTML = projects
522
+ .map((p) => {
523
+ const pcInstalled = p.postCommit?.installed;
524
+ const ppInstalled = p.prePush?.installed;
525
+ const allInstalled = pcInstalled && ppInstalled;
526
+ const noneInstalled = !pcInstalled && !ppInstalled;
527
+ const statusBadge = allInstalled
528
+ ? '<span class="hook-badge installed">설치됨</span>'
529
+ : noneInstalled
530
+ ? '<span class="hook-badge not-installed">미설치</span>'
531
+ : '<span class="hook-badge partial">일부 설치</span>';
532
+
533
+ const displayName = p.displayName || p.name;
534
+ return `<div class="hook-project-row" data-name="${escHtml(p.name)}">
535
+ <div class="hook-project-info">
536
+ <span class="hook-project-name">${escHtml(displayName)}</span>
537
+ ${statusBadge}
538
+ <span class="hook-detail">post-commit: ${pcInstalled ? "✅" : "❌"} &nbsp; pre-push: ${ppInstalled ? "✅" : "❌"}</span>
539
+ </div>
540
+ <div class="hook-project-actions">
541
+ ${
542
+ allInstalled
543
+ ? `<button class="btn-ghost hook-remove-btn" data-name="${escHtml(p.name)}">제거</button>`
544
+ : `<button class="btn-secondary hook-install-btn" data-name="${escHtml(p.name)}">설치</button>`
545
+ }
546
+ </div>
547
+ </div>`;
548
+ })
549
+ .join("");
550
+
551
+ listEl.querySelectorAll(".hook-install-btn").forEach((btn) => {
552
+ btn.addEventListener("click", () => handleHookAction("install", btn.dataset.name, btn));
553
+ });
554
+ listEl.querySelectorAll(".hook-remove-btn").forEach((btn) => {
555
+ btn.addEventListener("click", () => handleHookAction("remove", btn.dataset.name, btn));
556
+ });
557
+ } catch {
558
+ listEl.innerHTML =
559
+ '<p class="empty-state" style="color:var(--danger)">훅 상태를 불러오지 못했습니다.</p>';
560
+ }
561
+ }
562
+
563
+ async function handleHookAction(action, pName, btn) {
564
+ const original = btn.textContent;
565
+ btn.disabled = true;
566
+ btn.textContent = action === "install" ? "설치 중..." : "제거 중...";
567
+ try {
568
+ const res = await fetch(`/api/hooks/${action}`, {
569
+ method: "POST",
570
+ headers: { "Content-Type": "application/json" },
571
+ body: JSON.stringify({ projectName: pName }),
572
+ });
573
+ const data = await res.json();
574
+ if (!res.ok) throw new Error(data.error || "요청 실패");
575
+ await loadHookStatus();
576
+ } catch (err) {
577
+ btn.disabled = false;
578
+ btn.textContent = original;
579
+ alert(`오류: ${err.message}`);
580
+ }
581
+ }
582
+
513
583
  // ── Helpers ──
514
584
  function togglePre(preId, btnId) {
515
585
  const pre = document.getElementById(preId);
package/public/index.html CHANGED
@@ -21,7 +21,6 @@
21
21
  <link rel="stylesheet" href="/style.css" />
22
22
  </head>
23
23
  <body>
24
- <!-- ── Header ── -->
25
24
  <header class="header">
26
25
  <div class="header-inner">
27
26
  <div class="logo">
@@ -36,6 +35,9 @@
36
35
  <button class="nav-btn" data-tab="reports" id="btn-reports">
37
36
  리포트
38
37
  </button>
38
+ <button class="nav-btn" data-tab="hooks" id="btn-hooks">
39
+ 훅 관리
40
+ </button>
39
41
  </nav>
40
42
  <div class="aria-chip" id="aria-chip">
41
43
  <span class="aria-chip-dot" id="aria-chip-dot"></span>
@@ -44,11 +46,8 @@
44
46
  </div>
45
47
  </header>
46
48
 
47
- <!-- ── Main ── -->
48
49
  <main class="main">
49
- <!-- ── Tab: Analyze ── -->
50
50
  <section class="tab-content active" id="tab-analyze">
51
- <!-- ── Aria Hero ── -->
52
51
  <div class="aria-hero">
53
52
  <div class="aria-robot-wrap idle" id="aria-robot-wrap">
54
53
  <svg
@@ -80,7 +79,6 @@
80
79
  <stop offset="100%" stop-color="#0e7490" />
81
80
  </linearGradient>
82
81
  </defs>
83
- <!-- Antenna -->
84
82
  <line
85
83
  x1="45"
86
84
  y1="7"
@@ -158,7 +156,7 @@
158
156
  <div class="aria-bubble-wrap">
159
157
  <div class="aria-bubble">
160
158
  <span class="aria-bubble-text" id="aria-bubble-text"
161
- >어떤 프로젝트의 커밋을 분석해드릴까요?</span
159
+ >분석을 시작할까요?</span
162
160
  >
163
161
  <span
164
162
  class="aria-typing-dots"
@@ -179,14 +177,8 @@
179
177
  키를 입력해 주세요.
180
178
  </div>
181
179
 
182
- <!-- Project Selector Card -->
180
+ <!-- Selector Card -->
183
181
  <div class="card selector-card">
184
- <div class="card-header">
185
- <h2 class="card-title">🗂 프로젝트 선택</h2>
186
- <button class="refresh-btn" id="refresh-projects" title="새로고침">
187
-
188
- </button>
189
- </div>
190
182
  <!-- Mode Toggle -->
191
183
  <div class="mode-toggle">
192
184
  <button class="mode-btn active" id="mode-commit" data-mode="commit">
@@ -196,18 +188,8 @@
196
188
  🔍 현재 변경사항 분석
197
189
  </button>
198
190
  </div>
199
- <div class="project-grid" id="project-grid">
200
- <div class="skeleton-grid">
201
- <div class="skeleton"></div>
202
- <div class="skeleton"></div>
203
- <div class="skeleton"></div>
204
- </div>
205
- </div>
206
191
  <div class="card-footer">
207
- <span class="selected-hint" id="selected-hint"
208
- >프로젝트를 선택해 주세요</span
209
- >
210
- <button class="btn-primary" id="analyze-btn" disabled>
192
+ <button class="btn-primary" id="analyze-btn">
211
193
  <span class="btn-icon">🤖</span>
212
194
  <span id="analyze-btn-text">Hanni에게 분석 요청</span>
213
195
  </button>
@@ -265,6 +247,71 @@
265
247
  </div>
266
248
  </section>
267
249
 
250
+ <!-- ── Tab: Hooks ── -->
251
+ <section class="tab-content" id="tab-hooks">
252
+ <div class="card">
253
+ <div class="card-header">
254
+ <h2 class="card-title">🪝 Git Hook 관리</h2>
255
+ <button class="btn-ghost" id="refresh-hooks" title="새로고침">
256
+
257
+ </button>
258
+ </div>
259
+ <p class="hook-desc">
260
+ 훅을 설치하면 <strong>커밋할 때마다 자동으로 AI 분석</strong>이
261
+ 실행되고, <strong>push 전에 secret 유출을 자동 차단</strong>합니다.
262
+ </p>
263
+
264
+ <div class="hook-section">
265
+ <div class="hook-section-title">
266
+ <span class="hook-icon">⚡</span>
267
+ post-commit — 자동 커밋 분석
268
+ </div>
269
+ <p class="hook-section-desc">
270
+ 커밋 직후 Gemini AI가 백그라운드에서 분석 리포트를 자동
271
+ 생성합니다.
272
+ </p>
273
+ </div>
274
+
275
+ <div class="hook-section">
276
+ <div class="hook-section-title">
277
+ <span class="hook-icon">⛔</span>
278
+ pre-push — Secret 유출 탐지
279
+ </div>
280
+ <p class="hook-section-desc">
281
+ push 전에 API 키, 비밀번호, private key 등의 유출을 탐지하고
282
+ 차단합니다.
283
+ </p>
284
+ </div>
285
+
286
+ <div id="hook-projects-list" class="hook-projects-list">
287
+ <p class="empty-state">불러오는 중...</p>
288
+ </div>
289
+ </div>
290
+
291
+ <div class="card hook-cli-card">
292
+ <h2 class="card-title">💻 CLI 사용법</h2>
293
+ <div class="hook-cli-block">
294
+ <div class="hook-cli-item">
295
+ <code>commit-ai-agent hook install</code>
296
+ <span>현재 디렉토리에 훅 설치</span>
297
+ </div>
298
+
299
+ <div class="hook-cli-item">
300
+ <code>commit-ai-agent hook remove</code>
301
+ <span>훅 제거</span>
302
+ </div>
303
+ <div class="hook-cli-item">
304
+ <code>commit-ai-agent hook status</code>
305
+ <span>설치 상태 확인</span>
306
+ </div>
307
+ <div class="hook-cli-item">
308
+ <code>SKIP_SECRET_SCAN=1 git push</code>
309
+ <span>Secret 스캔 일시 스킵 (오탐 시)</span>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </section>
314
+
268
315
  <!-- ── Tab: Reports ── -->
269
316
  <section class="tab-content" id="tab-reports">
270
317
  <div class="card">