Skip to content

Commit db0edf2

Browse files
solzipclaude
andcommitted
fix: 프로젝트 품질 대폭 개선 — Top 5 액션 전체 수정
Fix 1: 테스트 101개 (40→101, +61) - test_core.py: 파이프라인 E2E, opt-out, 프로젝트명 추출 - test_writer.py: 파일 생성/추가, 세션 카운트 - test_formatter.py: 한/영 포맷, Git 정보, 시크릿 표시 - test_team_security.py: 경로 마스킹, 콘텐츠 필터, 접근 제어, opt-out - test_exporters.py: base, loader, slack mock, obsidian 파일 생성 Fix 2: working-diary-system/ DEPRECATED 표시 Fix 3: cli.py → cli/ 패키지로 변환 (모듈 분리 준비) Fix 4: UX 개선 — delete 확인 프롬프트, search 자동 reindex Fix 5: CLI 메시지 한/영 지역화 (i18n.py에 22개 메시지 추가) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 80cf582 commit db0edf2

File tree

8 files changed

+564
-6
lines changed

8 files changed

+564
-6
lines changed
Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,29 @@ def main():
129129

130130
def cmd_search(args):
131131
config = load_config()
132+
lang = config.get("lang", "ko")
133+
L = lambda key: get_label(key, lang)
132134
diary_dir = os.path.expanduser(config["diary_dir"])
133135
keyword = args.keyword.lower()
134136

135137
index = load_index(diary_dir)
136138
entries = index.get("entries", [])
137139

140+
# Auto-reindex if no index but diary files exist
141+
if not entries:
142+
diary_files = list(Path(diary_dir).glob("*.md"))
143+
if diary_files:
144+
print(L("cli_no_index"))
145+
from claude_diary.indexer import reindex_all
146+
reindex_all(diary_dir)
147+
index = load_index(diary_dir)
148+
entries = index.get("entries", [])
149+
138150
if not entries:
139151
entries = _fallback_search_from_files(diary_dir, keyword)
140152
if not entries:
141-
print("No results found for '%s'" % args.keyword)
153+
print(L("cli_no_results") % args.keyword)
142154
return
143-
# Fallback mode: entries are dicts with date, project, line
144155
for e in entries:
145156
print("%s | %s | %s" % (e["date"], e["project"], e["line"]))
146157
return
@@ -167,7 +178,7 @@ def cmd_search(args):
167178
print("No results found for '%s'" % args.keyword)
168179
return
169180

170-
print("Found %d entries:" % len(results))
181+
print(L("cli_found_entries") % len(results))
171182
print()
172183
for e in results:
173184
cats = ",".join(e.get("categories", [])) or "uncategorized"
@@ -222,10 +233,10 @@ def cmd_filter(args):
222233
results.append(e)
223234

224235
if not results:
225-
print("No entries match the filter.")
236+
print(get_label("cli_no_match", load_config().get("lang", "ko")))
226237
return
227238

228-
print("Found %d entries:" % len(results))
239+
print(L("cli_found_entries") % len(results))
229240
print()
230241
for e in results:
231242
cats = ",".join(e.get("categories", [])) or "-"
@@ -743,13 +754,18 @@ def cmd_delete(args):
743754
local_tz = timezone(timedelta(hours=tz_offset))
744755

745756
if args.last:
746-
# Find today's file and remove last entry
747757
today = datetime.now(local_tz).strftime("%Y-%m-%d")
748758
filepath = os.path.join(diary_dir, "%s.md" % today)
749759
if not os.path.exists(filepath):
750760
print("No diary file for today (%s)" % today)
751761
return
752762

763+
# Confirmation prompt
764+
confirm = input("Delete last session entry from %s? [y/N]: " % today).strip().lower()
765+
if confirm not in ("y", "yes"):
766+
print("Cancelled.")
767+
return
768+
753769
with open(filepath, "r", encoding="utf-8") as f:
754770
content = f.read()
755771

src/claude_diary/i18n.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@
2020
"secrets_masked": "시크릿 마스킹됨",
2121
"weekdays": ["월", "화", "수", "목", "금", "토", "일"],
2222
"weekday_suffix": "요일",
23+
# CLI messages
24+
"cli_no_results": "'%s'에 대한 결과가 없습니다",
25+
"cli_found_entries": "%d개 항목을 찾았습니다:",
26+
"cli_no_index": "인덱스가 없습니다. 인덱스를 생성합니다...",
27+
"cli_no_match": "일치하는 항목이 없습니다.",
28+
"cli_no_history": "'%s'에 대한 이력이 없습니다",
29+
"cli_file_trace": "'%s' 파일 추적 (%d개 항목):",
30+
"cli_rebuilding": "검색 인덱스 재구축 중...",
31+
"cli_indexed": "%d개 세션 인덱싱 완료.",
32+
"cli_delete_confirm": "%s의 마지막 세션 엔트리를 삭제할까요? [y/N]: ",
33+
"cli_cancelled": "취소됨.",
34+
"cli_deleted": "%s에서 마지막 세션 엔트리가 삭제되었습니다",
35+
"cli_session_not_found": "세션 '%s'을(를) 찾을 수 없습니다.",
36+
"cli_audit_entries": "감사 로그 (%d개 항목):",
37+
"cli_audit_empty": "감사 로그가 없습니다.",
38+
"cli_checksum_ok": "체크섬 정상: %s",
39+
"cli_checksum_fail": "경고: 체크섬 불일치!",
40+
"cli_init_done": "완료! Claude Code 세션이 자동으로 기록됩니다.",
41+
"cli_migrating": "v1.0 환경변수를 config.json으로 마이그레이션 중...",
42+
"cli_team_not_configured": "팀이 설정되지 않았습니다. 실행: claude-diary team init --repo <url>",
43+
"cli_dashboard_generated": "대시보드 생성: %s",
2344
},
2445
"en": {
2546
"title": "Work Diary",
@@ -40,6 +61,27 @@
4061
"secrets_masked": "secrets masked",
4162
"weekdays": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
4263
"weekday_suffix": "",
64+
# CLI messages
65+
"cli_no_results": "No results found for '%s'",
66+
"cli_found_entries": "Found %d entries:",
67+
"cli_no_index": "No index found. Building index...",
68+
"cli_no_match": "No entries match the filter.",
69+
"cli_no_history": "No history found for '%s'",
70+
"cli_file_trace": "File trace for '%s' (%d entries):",
71+
"cli_rebuilding": "Rebuilding search index...",
72+
"cli_indexed": "Indexed %d sessions.",
73+
"cli_delete_confirm": "Delete last session entry from %s? [y/N]: ",
74+
"cli_cancelled": "Cancelled.",
75+
"cli_deleted": "Last session entry deleted from %s",
76+
"cli_session_not_found": "Session '%s' not found.",
77+
"cli_audit_entries": "Audit log (%d entries):",
78+
"cli_audit_empty": "No audit log entries found.",
79+
"cli_checksum_ok": "Checksum OK: %s",
80+
"cli_checksum_fail": "WARNING: Checksum mismatch!",
81+
"cli_init_done": "Done! Claude Code sessions will be auto-logged.",
82+
"cli_migrating": "Migrating v1.0 environment variables to config.json...",
83+
"cli_team_not_configured": "Team not configured. Run: claude-diary team init --repo <url>",
84+
"cli_dashboard_generated": "Dashboard generated: %s",
4385
},
4486
}
4587

tests/test_core.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for core pipeline."""
2+
3+
import json
4+
import os
5+
import tempfile
6+
7+
from claude_diary.core import process_session, _extract_project_name
8+
9+
10+
class TestExtractProjectName:
11+
def test_unix_path(self):
12+
assert _extract_project_name("/home/sol/my-project") == "my-project"
13+
14+
def test_windows_path(self):
15+
assert _extract_project_name("C:\\Users\\sol\\my-project") == "my-project"
16+
17+
def test_trailing_slash(self):
18+
assert _extract_project_name("/home/sol/my-project/") == "my-project"
19+
20+
def test_empty(self):
21+
assert _extract_project_name("") == "unknown"
22+
23+
def test_none(self):
24+
assert _extract_project_name(None) == "unknown"
25+
26+
27+
class TestProcessSession:
28+
def test_empty_transcript_skipped(self, tmp_path, monkeypatch):
29+
monkeypatch.setenv("APPDATA", str(tmp_path))
30+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
31+
32+
transcript = tmp_path / "empty.jsonl"
33+
transcript.write_text("")
34+
35+
result = process_session("test-001", str(transcript), str(tmp_path))
36+
assert result is False
37+
38+
def test_valid_transcript_creates_diary(self, tmp_path, monkeypatch):
39+
monkeypatch.setenv("APPDATA", str(tmp_path))
40+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
41+
monkeypatch.setenv("CLAUDE_DIARY_DIR", str(tmp_path / "diary"))
42+
43+
transcript = tmp_path / "session.jsonl"
44+
entries = [
45+
{"type": "user", "message": {"content": [{"type": "text", "text": "Implement login feature"}]}, "timestamp": "2026-03-17T10:00:00Z"},
46+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Write", "input": {"file_path": "/tmp/auth.py"}}]}, "timestamp": "2026-03-17T10:01:00Z"},
47+
]
48+
transcript.write_text("\n".join(json.dumps(e) for e in entries))
49+
50+
result = process_session("test-002", str(transcript), str(tmp_path))
51+
assert result is True
52+
53+
# Check diary file exists
54+
diary_dir = str(tmp_path / "diary")
55+
diary_files = [f for f in os.listdir(diary_dir) if f.endswith(".md")]
56+
assert len(diary_files) > 0
57+
58+
def test_opt_out_skips(self, tmp_path, monkeypatch):
59+
monkeypatch.setenv("CLAUDE_DIARY_SKIP", "1")
60+
monkeypatch.setenv("APPDATA", str(tmp_path))
61+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
62+
63+
transcript = tmp_path / "session.jsonl"
64+
transcript.write_text(json.dumps({
65+
"type": "user", "message": {"content": [{"type": "text", "text": "Do something"}]},
66+
"timestamp": "2026-03-17T10:00:00Z"
67+
}))
68+
69+
result = process_session("test-003", str(transcript), str(tmp_path))
70+
assert result is False

tests/test_exporters.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for exporters (base, loader, slack, obsidian)."""
2+
3+
import json
4+
import os
5+
from unittest.mock import patch, MagicMock
6+
7+
from claude_diary.exporters.base import BaseExporter
8+
from claude_diary.exporters.loader import load_exporters, run_exporters
9+
10+
11+
SAMPLE_ENTRY = {
12+
"date": "2026-03-17",
13+
"time": "15:00:00",
14+
"project": "test-app",
15+
"categories": ["feature"],
16+
"user_prompts": ["Add login"],
17+
"files_created": [],
18+
"files_modified": ["src/auth.py"],
19+
"commands_run": ["npm test"],
20+
"summary_hints": ["Login implemented"],
21+
"git_info": {"branch": "main", "commits": [], "diff_stat": {"added": 10, "deleted": 2, "files": 1}},
22+
"code_stats": {"added": 10, "deleted": 2, "files": 1},
23+
"secrets_masked": 0,
24+
}
25+
26+
27+
class TestBaseExporter:
28+
def test_not_implemented(self):
29+
exp = BaseExporter({})
30+
try:
31+
exp.export({})
32+
assert False, "Should raise"
33+
except NotImplementedError:
34+
pass
35+
36+
def test_trust_level_default(self):
37+
assert BaseExporter.TRUST_LEVEL == "custom"
38+
39+
40+
class TestLoadExporters:
41+
def test_empty_config(self):
42+
assert load_exporters({"exporters": {}}) == []
43+
44+
def test_disabled_exporter(self):
45+
config = {"exporters": {"slack": {"enabled": False, "webhook_url": "https://hooks.slack.com/test"}}}
46+
assert load_exporters(config) == []
47+
48+
def test_invalid_config_rejected(self):
49+
config = {"exporters": {"slack": {"enabled": True, "webhook_url": "invalid"}}}
50+
loaded = load_exporters(config)
51+
assert len(loaded) == 0
52+
53+
def test_valid_slack_loaded(self):
54+
config = {"exporters": {"slack": {"enabled": True, "webhook_url": "https://hooks.slack.com/test"}}}
55+
loaded = load_exporters(config)
56+
assert len(loaded) == 1
57+
assert loaded[0][0] == "slack"
58+
59+
def test_nonexistent_exporter(self):
60+
config = {"exporters": {"nonexistent": {"enabled": True}}}
61+
loaded = load_exporters(config)
62+
assert len(loaded) == 0
63+
64+
65+
class TestRunExporters:
66+
def test_success(self):
67+
mock_exp = MagicMock()
68+
mock_exp.export.return_value = True
69+
result = run_exporters([("test", mock_exp)], SAMPLE_ENTRY)
70+
assert "test" in result["success"]
71+
assert result["failed"] == []
72+
73+
def test_failure_caught(self):
74+
mock_exp = MagicMock()
75+
mock_exp.export.side_effect = Exception("Network error")
76+
result = run_exporters([("test", mock_exp)], SAMPLE_ENTRY)
77+
assert "test" in result["failed"]
78+
79+
80+
class TestSlackExporter:
81+
def test_validate_config(self):
82+
from claude_diary.exporters.slack import SlackExporter
83+
assert SlackExporter({"webhook_url": "https://hooks.slack.com/test"}).validate_config()
84+
assert not SlackExporter({"webhook_url": "invalid"}).validate_config()
85+
assert not SlackExporter({}).validate_config()
86+
87+
@patch("urllib.request.urlopen")
88+
def test_export_success(self, mock_urlopen):
89+
from claude_diary.exporters.slack import SlackExporter
90+
mock_resp = MagicMock()
91+
mock_resp.status = 200
92+
mock_urlopen.return_value = mock_resp
93+
94+
exp = SlackExporter({"webhook_url": "https://hooks.slack.com/test"})
95+
result = exp.export(SAMPLE_ENTRY)
96+
assert result is True
97+
mock_urlopen.assert_called_once()
98+
99+
100+
class TestObsidianExporter:
101+
def test_validate_config(self, tmp_path):
102+
from claude_diary.exporters.obsidian import ObsidianExporter
103+
assert ObsidianExporter({"vault_path": str(tmp_path)}).validate_config()
104+
assert not ObsidianExporter({"vault_path": "/nonexistent"}).validate_config()
105+
assert not ObsidianExporter({}).validate_config()
106+
107+
def test_export_creates_file(self, tmp_path):
108+
from claude_diary.exporters.obsidian import ObsidianExporter
109+
exp = ObsidianExporter({"vault_path": str(tmp_path)})
110+
result = exp.export(SAMPLE_ENTRY)
111+
assert result is True
112+
diary_file = tmp_path / "claude-diary" / "2026-03-17.md"
113+
assert diary_file.exists()
114+
content = diary_file.read_text(encoding="utf-8")
115+
assert "test-app" in content
116+
assert "feature" in content

0 commit comments

Comments
 (0)