diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..057db50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug Report +about: Report a bug +labels: bug +--- + +## Environment +- OS: +- Python version: +- claude-diary version: + +## Steps to Reproduce +1. +2. +3. + +## Expected Behavior + + +## Actual Behavior + + +## Logs +``` +# Paste output of: claude-diary audit -n 5 +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b1c42ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature Request +about: Suggest a new feature +labels: enhancement +--- + +## Use Case + + +## Proposed Solution + + +## Alternatives Considered + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3ff6829 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + + +## Type +- [ ] Bug fix +- [ ] New feature +- [ ] New exporter +- [ ] Documentation +- [ ] Other + +## Checklist +- [ ] Tests pass (`pytest tests/`) +- [ ] No external dependencies added to core +- [ ] Documentation updated (KO/EN if applicable) +- [ ] No secrets in code diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f24af1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main, phase-b, phase-c] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.7", "3.9", "3.11", "3.12"] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + pip install -e . + + - name: Run tests + run: | + pytest tests/ -v --cov=claude_diary --cov-report=term-missing + + - name: Check coverage + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + run: | + pytest tests/ --cov=claude_diary --cov-fail-under=70 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af8e1fb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release to PyPI + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71b27a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [3.0.0] - 2026-03-17 (Phase B) + +### Added +- **Security**: Audit log system (`.audit.jsonl`) — every Hook execution recorded +- **Security**: SHA-256 checksum verification (`claude-diary audit --verify`) +- **Security**: SECURITY.md with transparency documentation +- **Testing**: 40 unit tests (parser, categorizer, secret_scanner, config, audit) +- **CI/CD**: GitHub Actions (Python 3.7~3.12 × 3 OS) +- **CI/CD**: PyPI auto-release on tag push +- **Community**: LICENSE (MIT), CONTRIBUTING.md, Issue/PR templates +- **Community**: CHANGELOG.md + +### Changed +- Audit log integrated into core pipeline + +## [2.0.0] - 2026-03-17 (Phase A) + +### Added +- **Core**: Modular pip package structure (`src/claude_diary/`) +- **Core**: Auto-categorization (7 categories, KO/EN keywords) +- **Core**: Git integration (branch, commits, diff stats) +- **Core**: Secret scanner (11+ patterns auto-masked) +- **Core**: Search index (`.diary_index.json`) for fast CLI queries +- **CLI**: 11 subcommands (search, filter, trace, stats, weekly, config, init, migrate, reindex, audit, dashboard) +- **Exporters**: Plugin architecture with 5 official exporters (Notion, Slack, Discord, Obsidian, GitHub) +- **Dashboard**: HTML dashboard with Chart.js (heatmap, charts, dark theme) +- **Config**: XDG standard paths, environment variable fallback + +### Changed +- Refactored from single script to modular package +- Config priority: `config.json > env vars > defaults` + +## [1.0.0] - 2026-03-17 + +### Added +- Initial release +- Stop Hook auto-diary (transcript parsing) +- Weekly summary generator +- Korean/English bilingual support +- Windows/macOS/Linux cross-platform +- `install.sh` auto-installer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ec0f4e8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to claude-diary + +> [한국어](#한국어) | [English](#english) + +## English + +Thank you for your interest in contributing to claude-diary! + +### How to Contribute + +#### Reporting Bugs +- Use the [Bug Report](https://github.com/solzip/claude-code-hooks-diary/issues/new?template=bug_report.md) template +- Include: OS, Python version, steps to reproduce + +#### Feature Requests +- Use the [Feature Request](https://github.com/solzip/claude-code-hooks-diary/issues/new?template=feature_request.md) template + +#### Contributing an Exporter +1. Inherit from `exporters/base.py` → `BaseExporter` +2. Implement `export()` and `validate_config()` +3. Set `TRUST_LEVEL = "community"` +4. Write tests in `tests/test_exporters/` +5. Submit PR + +#### Rules +- Core code modifications are not accepted (exporters/categorizers only) +- External dependencies must be optional (`[project.optional-dependencies]`) +- Documentation in both Korean and English +- Security review required for all PRs +- All tests must pass (`pytest tests/`) + +### Development Setup + +```bash +git clone https://github.com/solzip/claude-code-hooks-diary.git +cd claude-code-hooks-diary +pip install pytest +PYTHONPATH=src pytest tests/ -v +``` + +### Exporter Trust Levels + +| Level | Description | +|-------|-------------| +| 🟢 Official | Included in the project, code-reviewed | +| 🟡 Community | Contributed via PR, reviewed | +| 🔴 Custom | User-created, not verified | + +--- + +## 한국어 + +claude-diary에 기여해 주셔서 감사합니다! + +### 기여 방법 + +#### 버그 리포트 +- [Bug Report](https://github.com/solzip/claude-code-hooks-diary/issues/new?template=bug_report.md) 템플릿 사용 +- OS, Python 버전, 재현 단계 포함 + +#### 기능 요청 +- [Feature Request](https://github.com/solzip/claude-code-hooks-diary/issues/new?template=feature_request.md) 템플릿 사용 + +#### Exporter 기여 +1. `exporters/base.py`의 `BaseExporter` 상속 +2. `export()`, `validate_config()` 구현 +3. `TRUST_LEVEL = "community"` 설정 +4. `tests/test_exporters/`에 테스트 작성 +5. PR 제출 + +#### 규칙 +- 코어 코드 수정은 받지 않습니다 (exporter/categorizer만) +- 외부 의존성은 optional dependency로 추가 +- 한국어/영어 문서 모두 작성 +- 모든 PR은 보안 리뷰 필수 +- 모든 테스트 통과 필수 (`pytest tests/`) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2035615 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 solzip + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..62545e4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## What This Tool Does + +- Reads Claude Code transcript files (read-only) +- Writes diary entries to `~/working-diary/` directory +- Scans for secrets before writing (auto-masking) +- Logs all operations to `.audit.jsonl` + +## What This Tool Does NOT Do + +- Does NOT send data to external servers (core has zero network access) +- Does NOT modify your source code or Claude Code configuration +- Does NOT access files outside the diary directory (except reading transcripts) +- Does NOT store or transmit API tokens (exporter configs are local-only) + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 3.x | Yes | +| 2.x | Yes | +| 1.x | No | + +## Security Features + +- **Secret scanning**: Auto-masks passwords, API keys, tokens before writing +- **Audit log**: Every Hook execution is logged with checksums +- **Checksum verification**: `claude-diary audit --verify` detects source tampering +- **Exporter isolation**: Exporters receive processed data only (no transcript access) +- **Config protection**: File permissions 600 on Unix, tokens masked in CLI output +- **Exporter trust levels**: Official / Community / Custom classification + +## Reporting a Vulnerability + +- **Email**: solzip@users.noreply.github.com +- **Do NOT** open a public issue for security vulnerabilities +- Expected response time: within 48 hours +- We will coordinate disclosure after a fix is available + +## Verifying Integrity + +```bash +# Verify source code hasn't been tampered with +claude-diary audit --verify + +# Review recent Hook executions +claude-diary audit --days 7 +``` diff --git a/src/claude_diary/cli.py b/src/claude_diary/cli.py index 8c7e90c..418bbe5 100644 --- a/src/claude_diary/cli.py +++ b/src/claude_diary/cli.py @@ -68,6 +68,12 @@ def main(): # reindex sub.add_parser("reindex", help="Rebuild search index") + # audit + p_audit = sub.add_parser("audit", help="View audit log and verify integrity") + p_audit.add_argument("--days", type=int, help="Show entries from last N days") + p_audit.add_argument("--verify", action="store_true", help="Verify source code checksum") + p_audit.add_argument("-n", type=int, default=10, help="Number of entries (default: 10)") + # dashboard p_dashboard = sub.add_parser("dashboard", help="Generate HTML dashboard") p_dashboard.add_argument("--serve", action="store_true", help="Start local server") @@ -90,6 +96,7 @@ def main(): "init": cmd_init, "migrate": cmd_migrate, "reindex": cmd_reindex, + "audit": cmd_audit, "dashboard": cmd_dashboard, } @@ -594,6 +601,50 @@ def cmd_reindex(args): print("Index: %s" % index_path) +# ── Audit ── + +def cmd_audit(args): + from claude_diary.lib.audit import read_audit_log, verify_checksum + config = load_config() + diary_dir = os.path.expanduser(config["diary_dir"]) + + if args.verify: + is_valid, current, last = verify_checksum(diary_dir) + if is_valid: + print("Checksum OK: %s" % current) + else: + print("WARNING: Checksum mismatch!") + print(" Current: %s" % current) + print(" Last log: %s" % last) + print(" Source files may have been modified since last Hook execution.") + return + + entries = read_audit_log(diary_dir, days=args.days, limit=args.n) + + if not entries: + print("No audit log entries found.") + return + + print("Audit log (%d entries):" % len(entries)) + print() + for e in entries: + ts = e.get("timestamp", "")[:19] + sid = e.get("session_id", "")[:8] + masked = e.get("secrets_masked", 0) + written = len(e.get("files_written", [])) + exporters = e.get("exporters_called", []) + failed = e.get("exporters_failed", []) + + line = " %s | session:%s | wrote:%d" % (ts, sid, written) + if masked > 0: + line += " | secrets_masked:%d" % masked + if exporters: + line += " | exporters:%s" % ",".join(exporters) + if failed: + line += " | FAILED:%s" % ",".join(failed) + print(line) + + # ── Dashboard ── def cmd_dashboard(args): diff --git a/src/claude_diary/core.py b/src/claude_diary/core.py index 26b150a..8c6e204 100644 --- a/src/claude_diary/core.py +++ b/src/claude_diary/core.py @@ -10,6 +10,7 @@ from claude_diary.lib.categorizer import categorize from claude_diary.lib.secret_scanner import scan_entry_data from claude_diary.formatter import format_entry +from claude_diary.lib.audit import log_entry as audit_log from claude_diary.writer import append_entry, update_session_count, ensure_diary_dir from claude_diary.indexer import update_index @@ -129,7 +130,21 @@ def process_session(session_id, transcript_path, cwd): except Exception: pass - # 9. Log success + # 10. Audit log (non-critical) + try: + diary_file = os.path.join(diary_dir, "%s.md" % date_str) + audit_log( + diary_dir=diary_dir, + session_id=session_id, + transcript_path=transcript_path, + files_written=[diary_file], + secrets_masked=entry_data.get("secrets_masked", 0), + tz_offset=tz_offset, + ) + except Exception: + pass + + # 11. Log success sys.stderr.write( "[diary] Session #%d for %s | project: %s | categories: %s\n" % (count, date_str, project, ",".join(entry_data["categories"]) or "none") diff --git a/src/claude_diary/lib/audit.py b/src/claude_diary/lib/audit.py new file mode 100644 index 0000000..c195977 --- /dev/null +++ b/src/claude_diary/lib/audit.py @@ -0,0 +1,133 @@ +"""Audit log system — records every Hook execution for transparency and verification.""" + +import hashlib +import json +import os +import sys +from datetime import datetime, timezone, timedelta + + +def get_audit_path(diary_dir): + """Return path to audit log file.""" + return os.path.join(diary_dir, ".audit.jsonl") + + +def log_entry(diary_dir, session_id, transcript_path, files_written, + secrets_masked=0, exporters_called=None, exporters_failed=None, + tz_offset=9): + """Append an audit log entry. + + Args: + diary_dir: Path to diary directory + session_id: Claude session ID + transcript_path: Path to transcript that was read + files_written: List of files that were written + secrets_masked: Number of secrets masked + exporters_called: List of exporter names called + exporters_failed: List of exporter names that failed + tz_offset: Timezone offset for timestamp + """ + local_tz = timezone(timedelta(hours=tz_offset)) + now = datetime.now(local_tz) + + entry = { + "timestamp": now.isoformat(), + "session_id": session_id, + "action": "diary_entry_created", + "files_read": [transcript_path] if transcript_path else [], + "files_written": files_written or [], + "secrets_masked": secrets_masked, + "exporters_called": exporters_called or [], + "exporters_failed": exporters_failed or [], + "checksum": _compute_source_checksum(), + } + + audit_path = get_audit_path(diary_dir) + try: + with open(audit_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception as e: + sys.stderr.write("[diary] Audit log write failed: %s\n" % str(e)) + + +def read_audit_log(diary_dir, days=None, limit=10): + """Read audit log entries. + + Args: + diary_dir: Path to diary directory + days: Filter to last N days (None = no filter) + limit: Max entries to return + + Returns: + List of audit entry dicts (newest first) + """ + audit_path = get_audit_path(diary_dir) + if not os.path.exists(audit_path): + return [] + + entries = [] + try: + with open(audit_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + except Exception: + return [] + + # Filter by days + if days is not None: + cutoff = datetime.now().isoformat()[:10] + from datetime import timedelta as td + cutoff_date = (datetime.now() - td(days=days)).isoformat()[:10] + entries = [e for e in entries if e.get("timestamp", "")[:10] >= cutoff_date] + + # Return newest first, limited + entries.reverse() + return entries[:limit] + + +def verify_checksum(diary_dir): + """Verify source code integrity by comparing checksums. + + Returns: + (is_valid, current_checksum, last_logged_checksum) + """ + current = _compute_source_checksum() + + # Get last logged checksum + entries = read_audit_log(diary_dir, limit=1) + if not entries: + return (True, current, None) + + last_checksum = entries[0].get("checksum", "") + is_valid = (current == last_checksum) if last_checksum else True + + return (is_valid, current, last_checksum) + + +def _compute_source_checksum(): + """Compute SHA-256 hash of core source files for tamper detection.""" + hasher = hashlib.sha256() + + # Hash key source files + src_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + key_files = [ + "core.py", "hook.py", "config.py", + os.path.join("lib", "parser.py"), + os.path.join("lib", "secret_scanner.py"), + ] + + for rel_path in key_files: + full_path = os.path.join(src_dir, rel_path) + if os.path.exists(full_path): + try: + with open(full_path, "rb") as f: + hasher.update(f.read()) + except Exception: + pass + + return "sha256:" + hasher.hexdigest()[:16] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..3cd0d3a --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,51 @@ +"""Tests for audit log system.""" + +import json +import os + +from claude_diary.lib.audit import log_entry, read_audit_log, verify_checksum + + +class TestAuditLog: + def test_log_creates_file(self, tmp_path): + diary_dir = str(tmp_path) + log_entry(diary_dir, "session-001", "/tmp/transcript.jsonl", ["/tmp/diary.md"]) + audit_file = tmp_path / ".audit.jsonl" + assert audit_file.exists() + + def test_log_content(self, tmp_path): + diary_dir = str(tmp_path) + log_entry(diary_dir, "session-001", "/tmp/t.jsonl", ["/tmp/d.md"], secrets_masked=3) + entries = read_audit_log(diary_dir) + assert len(entries) == 1 + assert entries[0]["session_id"] == "session-001" + assert entries[0]["secrets_masked"] == 3 + assert entries[0]["checksum"].startswith("sha256:") + + def test_multiple_entries(self, tmp_path): + diary_dir = str(tmp_path) + log_entry(diary_dir, "s1", "", [], secrets_masked=0) + log_entry(diary_dir, "s2", "", [], secrets_masked=1) + log_entry(diary_dir, "s3", "", [], secrets_masked=2) + entries = read_audit_log(diary_dir, limit=2) + assert len(entries) == 2 + assert entries[0]["session_id"] == "s3" # newest first + + def test_read_empty(self, tmp_path): + entries = read_audit_log(str(tmp_path)) + assert entries == [] + + +class TestVerifyChecksum: + def test_first_run_valid(self, tmp_path): + is_valid, current, last = verify_checksum(str(tmp_path)) + assert is_valid is True + assert current.startswith("sha256:") + assert last is None + + def test_consistent_checksum(self, tmp_path): + diary_dir = str(tmp_path) + log_entry(diary_dir, "s1", "", []) + is_valid, current, last = verify_checksum(diary_dir) + assert is_valid is True + assert current == last diff --git a/tests/test_categorizer.py b/tests/test_categorizer.py new file mode 100644 index 0000000..6740140 --- /dev/null +++ b/tests/test_categorizer.py @@ -0,0 +1,46 @@ +"""Tests for auto-categorizer.""" + +from claude_diary.lib.categorizer import categorize + + +class TestCategorize: + def test_feature_detection_korean(self): + entry = {"user_prompts": ["새로운 기능을 구현해줘"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + cats = categorize(entry) + assert "feature" in cats + + def test_bugfix_detection_english(self): + entry = {"user_prompts": ["fix the login bug"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + cats = categorize(entry) + assert "bugfix" in cats + + def test_refactor_detection(self): + entry = {"user_prompts": ["refactor the authentication module"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + cats = categorize(entry) + assert "refactor" in cats + + def test_docs_from_file_extension(self): + entry = {"user_prompts": ["update the file"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": ["README.md"]} + cats = categorize(entry) + assert "docs" in cats + + def test_test_from_file_pattern(self): + entry = {"user_prompts": ["check this"], "summary_hints": [], "commands_run": [], "files_created": ["test_auth.py"], "files_modified": []} + cats = categorize(entry) + assert "test" in cats + + def test_max_three_categories(self): + entry = {"user_prompts": ["implement new feature, fix bug, refactor code, update docs, add tests"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + cats = categorize(entry) + assert len(cats) <= 3 + + def test_empty_entry(self): + entry = {"user_prompts": [], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + cats = categorize(entry) + assert cats == [] + + def test_custom_rules(self): + entry = {"user_prompts": ["perf optimization needed"], "summary_hints": [], "commands_run": [], "files_created": [], "files_modified": []} + custom = {"performance": ["perf", "optimization", "benchmark"]} + cats = categorize(entry, custom_rules=custom) + assert "performance" in cats diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..24bd543 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,60 @@ +"""Tests for configuration management.""" + +import json +import os +import tempfile +import pytest + +from claude_diary.config import load_config, save_config, get_config_dir, DEFAULT_CONFIG + + +class TestLoadConfig: + def test_defaults(self, monkeypatch): + monkeypatch.delenv("CLAUDE_DIARY_LANG", raising=False) + monkeypatch.delenv("CLAUDE_DIARY_DIR", raising=False) + monkeypatch.delenv("CLAUDE_DIARY_TZ_OFFSET", raising=False) + monkeypatch.setenv("XDG_CONFIG_HOME", "/nonexistent_config_dir") + monkeypatch.setenv("APPDATA", "/nonexistent_config_dir") + config = load_config() + assert config["lang"] == "ko" + assert config["timezone_offset"] == 9 + + def test_env_var_override(self, monkeypatch): + monkeypatch.setenv("CLAUDE_DIARY_LANG", "en") + monkeypatch.setenv("CLAUDE_DIARY_TZ_OFFSET", "-5") + monkeypatch.setenv("XDG_CONFIG_HOME", "/nonexistent_config_dir") + monkeypatch.setenv("APPDATA", "/nonexistent_config_dir") + config = load_config() + assert config["lang"] == "en" + assert config["timezone_offset"] == -5 + + def test_config_file_overrides_env(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLAUDE_DIARY_LANG", "en") + config_dir = tmp_path / "claude-diary" + config_dir.mkdir() + config_file = config_dir / "config.json" + config_file.write_text(json.dumps({"lang": "ko"})) + # Set both XDG and APPDATA so it works on all platforms + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + config = load_config() + # config.json > env vars + assert config["lang"] == "ko" + + def test_invalid_tz_offset_ignored(self, monkeypatch): + monkeypatch.setenv("CLAUDE_DIARY_TZ_OFFSET", "not_a_number") + monkeypatch.setenv("XDG_CONFIG_HOME", "/nonexistent_config_dir") + monkeypatch.setenv("APPDATA", "/nonexistent_config_dir") + config = load_config() + assert config["timezone_offset"] == 9 # default + + +class TestSaveConfig: + def test_creates_directory_and_file(self, monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("APPDATA", str(tmp_path)) + save_config({"lang": "en", "timezone_offset": -5}) + config_file = tmp_path / "claude-diary" / "config.json" + assert config_file.exists() + data = json.loads(config_file.read_text()) + assert data["lang"] == "en" diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..2389cdb --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,116 @@ +"""Tests for transcript parser.""" + +import json +import os +import tempfile +import pytest + +from claude_diary.lib.parser import parse_transcript, get_session_time_range + + +def _write_transcript(lines): + """Write a temporary JSONL transcript file.""" + f = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False, encoding="utf-8") + for entry in lines: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + f.close() + return f.name + + +class TestParseTranscript: + def test_empty_file(self, tmp_path): + p = tmp_path / "empty.jsonl" + p.write_text("") + result = parse_transcript(str(p)) + assert result["user_prompts"] == [] + assert result["files_created"] == [] + + def test_nonexistent_file(self): + result = parse_transcript("/nonexistent/file.jsonl") + assert result["user_prompts"] == [] + + def test_user_message_extraction(self): + path = _write_transcript([ + {"type": "user", "message": {"content": [{"type": "text", "text": "Fix the login bug"}]}, "timestamp": "2026-03-17T10:00:00Z"}, + ]) + try: + result = parse_transcript(path) + assert len(result["user_prompts"]) == 1 + assert "login bug" in result["user_prompts"][0] + finally: + os.unlink(path) + + def test_tool_use_extraction(self): + path = _write_transcript([ + {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "name": "Write", "input": {"file_path": "/tmp/test.py"}}, + {"type": "tool_use", "name": "Edit", "input": {"file_path": "/tmp/existing.py"}}, + {"type": "tool_use", "name": "Bash", "input": {"command": "npm test"}}, + ]}, "timestamp": "2026-03-17T10:01:00Z"}, + ]) + try: + result = parse_transcript(path) + assert len(result["files_created"]) == 1 + assert len(result["files_modified"]) == 1 + assert "npm test" in result["commands_run"] + assert "Write" in result["tools_used"] + assert "Edit" in result["tools_used"] + assert "Bash" in result["tools_used"] + finally: + os.unlink(path) + + def test_noise_commands_filtered(self): + path = _write_transcript([ + {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "name": "Bash", "input": {"command": "ls -la"}}, + {"type": "tool_use", "name": "Bash", "input": {"command": "cat file.txt"}}, + {"type": "tool_use", "name": "Bash", "input": {"command": "npm run build"}}, + ]}, "timestamp": "2026-03-17T10:01:00Z"}, + ]) + try: + result = parse_transcript(path) + assert len(result["commands_run"]) == 1 + assert "npm run build" in result["commands_run"] + finally: + os.unlink(path) + + def test_summary_hints_extraction(self): + path = _write_transcript([ + {"type": "assistant", "message": {"content": [ + {"type": "text", "text": "Circuit breaker pattern implemented successfully."}, + ]}, "timestamp": "2026-03-17T10:01:00Z"}, + ]) + try: + result = parse_transcript(path) + assert len(result["summary_hints"]) > 0 + finally: + os.unlink(path) + + def test_short_prompts_filtered(self): + path = _write_transcript([ + {"type": "user", "message": {"content": [{"type": "text", "text": "yes"}]}, "timestamp": "2026-03-17T10:00:00Z"}, + ]) + try: + result = parse_transcript(path) + assert len(result["user_prompts"]) == 0 + finally: + os.unlink(path) + + +class TestSessionTimeRange: + def test_extracts_timestamps(self): + path = _write_transcript([ + {"type": "user", "timestamp": "2026-03-17T10:00:00Z", "message": {"content": "hello"}}, + {"type": "assistant", "timestamp": "2026-03-17T10:30:00Z", "message": {"content": "hi"}}, + ]) + try: + start, end = get_session_time_range(path) + assert start == "2026-03-17T10:00:00Z" + assert end == "2026-03-17T10:30:00Z" + finally: + os.unlink(path) + + def test_nonexistent_file(self): + start, end = get_session_time_range("/nonexistent") + assert start is None + assert end is None diff --git a/tests/test_secret_scanner.py b/tests/test_secret_scanner.py new file mode 100644 index 0000000..ebccd7d --- /dev/null +++ b/tests/test_secret_scanner.py @@ -0,0 +1,85 @@ +"""Tests for secret scanner.""" + +from claude_diary.lib.secret_scanner import scan_and_mask, scan_entry_data + + +class TestScanAndMask: + def test_password_detection(self): + text = "password=mysecretpass123" + masked, count = scan_and_mask(text) + assert "mysecretpass123" not in masked + assert count > 0 + + def test_api_key_detection(self): + text = "api_key=abcdef123456" + masked, count = scan_and_mask(text) + assert "abcdef123456" not in masked + assert count > 0 + + def test_openai_key(self): + text = "Using key sk-abcdefghijklmnopqrstuvwx" + masked, count = scan_and_mask(text) + assert "sk-abcdefghijklmnopqrstuvwx" not in masked + assert "****" in masked + + def test_github_pat(self): + text = "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789" + masked, count = scan_and_mask(text) + assert "ghp_" not in masked + assert count > 0 + + def test_aws_key(self): + text = "AKIAIOSFODNN7EXAMPLE" + masked, count = scan_and_mask(text) + assert "AKIAIOSFODNN7EXAMPLE" not in masked + + def test_bearer_token(self): + text = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test" + masked, count = scan_and_mask(text) + assert "eyJhbGciOiJIUzI1NiJ9" not in masked + + def test_no_false_positive_on_normal_text(self): + text = "This is a normal sentence about programming" + masked, count = scan_and_mask(text) + assert masked == text + assert count == 0 + + def test_empty_string(self): + masked, count = scan_and_mask("") + assert masked == "" + assert count == 0 + + def test_none_input(self): + masked, count = scan_and_mask(None) + assert masked is None + assert count == 0 + + +class TestScanEntryData: + def test_masks_prompts(self): + entry = { + "user_prompts": ["set password=secret123 in config"], + "summary_hints": [], + "commands_run": [], + } + total = scan_entry_data(entry) + assert total > 0 + assert "secret123" not in entry["user_prompts"][0] + + def test_masks_commands(self): + entry = { + "user_prompts": [], + "summary_hints": [], + "commands_run": ["export API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456"], + } + total = scan_entry_data(entry) + assert total > 0 + + def test_sets_secrets_masked_count(self): + entry = { + "user_prompts": ["token=abc123secret"], + "summary_hints": ["Used password=test"], + "commands_run": [], + } + scan_entry_data(entry) + assert entry["secrets_masked"] > 0