claude-dotfiles: Claude Code 세션을 자동으로 마크다운으로 export하는 SessionEnd 훅 만들기
Claude Code 세션이 끝나면 대화 내용을 자동으로 마크다운 파일로 저장하는 훅을 만들었습니다. 텍스트 대화만 추출하고 API 키 같은 민감 정보는 마스킹 처리해서, LearningCollector 같은 파이프라인이 그대로 주워가 블로그 초안을 만들 수 있게 했습니다.
요약
2026년 7월 8일, claude-dotfiles라는 새 저장소를 만들면서 Claude Code의 SessionEnd 훅과 이를 설치해주는 스크립트를 작성했습니다. 커밋 메시지 그대로 옮기면 "Add Claude Code SessionEnd export hook + installer"인데, 핵심은 세션 트랜스크립트를 파싱해서 텍스트 턴만 뽑아내고(비밀값은 마스킹) ChatGPT/Gemini/Claude Exporter 확장 프로그램이 만드는 것과 같은 포맷의 Claude-*.md 파일로 저장하는 것입니다. 이렇게 저장하면 LearningCollector 같은 도구가 별도 파서 없이 기존 포맷 그대로 이 파일들을 집어갈 수 있습니다.
install.py는 이 훅 스크립트를 ~/.claude/hooks/에 복사하고, ~/.claude/settings.json에 SessionEnd 훅 설정과 CLAUDE_CHAT_EXPORT_DIR 환경변수를 병합해줍니다. 기존 설정을 덮어쓰지 않는 게 포인트입니다.
배경 및 목적
저는 여러 컴퓨터에서 Claude Code를 씁니다. 문제는 Claude Code 설정이 계정 단위로 동기화되지 않고 컴퓨터마다 ~/.claude/가 완전히 독립적이라는 점입니다. 새 컴퓨터를 세팅할 때마다 훅 스크립트를 손으로 복사하고 settings.json을 수정하는 게 번거로웠습니다.
또 다른 배경은 LearningCollector 프로젝트입니다. 이미 ChatGPT/Gemini/Claude Exporter 브라우저 확장으로 내보낸 대화 기록을 자동으로 수집해서 학습 기록 블로그 초안을 만드는 파이프라인을 만들어놨는데, Claude Code에서 나눈 대화(코드 짜면서 나눈 질문/답변)는 이 파이프라인에 들어오지 않고 있었습니다. Claude Code 세션도 브라우저 확장이 만드는 것과 같은 포맷으로 export하면, LearningCollector 쪽 코드를 하나도 안 건드리고 그냥 파일만 던져줘도 알아서 처리될 거라고 생각했습니다.
세 번째는 보안입니다. Claude Code 세션 중에 .env 파일을 읽거나 API 키를 붙여넣는 일이 종종 있는데, 이걸 그대로 export해서 공유 폴더에 평문으로 흘려보내면 안 되니 마스킹 로직이 필요했습니다.
구현 내용
이번 커밋에서 변경된 파일은 .gitignore, README.md, claude/hooks/export_session_to_chat_log.py, install.py 네 개이고, 총 301줄이 추가됐습니다(삭제는 0줄, 완전히 새로 만든 저장소라).
훅 스크립트: export_session_to_chat_log.py (157줄)
가장 핵심 로직은 세 부분으로 나뉩니다.
1. 비밀값 마스킹
GitHub 토큰, Google API 키, Groq 키, OpenAI 스타일 키, Perplexity 키 등 흔한 패턴을 정규식으로 미리 등록해두고, .env 스타일의 SOMETHING_KEY=값 한 줄 전체도 잡아내도록 했습니다.
SECRET_PATTERNS = [
re.compile(r'ghp_[A-Za-z0-9]{20,}'),
re.compile(r'AIzaSy[A-Za-z0-9_-]{25,}'),
re.compile(r'sk-[A-Za-z0-9]{20,}'),
re.compile(r'(?im)^([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)[A-Z0-9_]*\s*=\s*)\S+$'),
]
def redact_secrets(text: str) -> str:
for pattern in SECRET_PATTERNS:
if pattern.groups:
text = pattern.sub(r'\1[REDACTED]', text)
else:
text = pattern.sub('[REDACTED]', text)
return text
패턴에 캡처 그룹이 있는지(.env 라인처럼 키 이름은 남기고 값만 지우고 싶은 경우) 없는지에 따라 치환 방식을 분기한 게 소소한 디테일입니다.
2. 트랜스크립트 파싱
Claude Code 세션 트랜스크립트는 JSONL 형식으로 저장되는데, 여기서 실제 텍스트 응답만 뽑아내고 tool_use, tool_result, thinking 블록은 걸러내야 했습니다.
def extract_text_blocks(content) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
texts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if text:
texts.append(text)
return "\n\n".join(texts).strip()
return ""
message.content가 문자열일 때와 블록 리스트일 때를 둘 다 처리하도록 했습니다. 도구 호출 관련 내용을 넣으면 대화 흐름이 지저분해지고, 블로그 초안 재료로도 부적합하다고 판단해서 텍스트 턴만 남겼습니다.
3. 파일 생성 및 안전장치
훅은 stdin으로 JSON을 받는데, 이 입력을 못 읽거나 트랜스크립트 경로가 없으면 조용히 종료하도록 했습니다. 세션 종료 자체를 막으면 안 되기 때문입니다.
def main():
try:
hook_input = json.load(sys.stdin)
except Exception:
return
transcript_path = hook_input.get("transcript_path")
...
if len(turns) < 2:
return # 세션 열자마자 종료한 경우엔 파일 안 만듦
파일명은 Claude-{제목}_{타임스탬프}.md 형식으로, 트랜스크립트에 ai-title 엔트리가 있으면 그걸 쓰고 없으면 작업 디렉토리 이름을 fallback으로 씁니다.
설치 스크립트: install.py (88줄)
install.py는 두 가지 일을 합니다. 훅 스크립트를 ~/.claude/hooks/로 복사하고, ~/.claude/settings.json을 읽어서 필요한 부분만 병합합니다.
def merge_settings(settings: dict, export_dir: str) -> dict:
settings.setdefault("env", {})
settings["env"]["CLAUDE_CHAT_EXPORT_DIR"] = export_dir
settings.setdefault("hooks", {})
settings["hooks"].setdefault("SessionEnd", [])
already_registered = any(
HOOK_SCRIPT_NAME in h.get("command", "")
for entry in settings["hooks"]["SessionEnd"]
for h in entry.get("hooks", [])
)
if not already_registered:
settings["hooks"]["SessionEnd"].append({
"matcher": "*",
"hooks": [{"type": "command", "command": HOOK_COMMAND}]
})
return settings
load_settings로 기존 설정을 불러온 뒤 setdefault로 없는 키만 채우고, 이미 훅이 등록돼 있으면 중복 추가하지 않도록 체크했습니다. 이렇게 해야 다른 프로젝트에서 등록해둔 훅이나 설정이 덮어써지지 않습니다.
기술적 의사결정
왜 트랜스크립트를 JSONL로 직접 파싱했나
Claude Code가 세션 로그를 JSONL로 저장한다는 걸 알고 있었기 때문에 별도 라이브러리 없이 표준 json 모듈로 한 줄씩 읽는 방식을 택했습니다. 외부 의존성을 추가하면 install.py에서 pip install까지 신경 써야 하는데, 이 정도 파싱은 표준 라이브러리만으로 충분했습니다.
왜 기존 Exporter 포맷을 그대로 따랐나
새로운 포맷을 정의할 수도 있었지만, 그렇게 하면 LearningCollector 쪽 파서를 새로 만들거나 분기 처리를 추가해야 합니다. 이미 Claude-*.md 형식으로 브라우저 확장 export를 처리하는 로직이 있으니, 훅 쪽에서 같은 포맷을 맞춰주는 게 훨씬 적은 작업이었습니다. 포맷 통일 덕분에 이번 커밋에서 LearningCollector 코드는 한 줄도 건드리지 않았습니다.
왜 settings.json을 통째로 덮어쓰지 않고 병합했나
~/.claude/settings.json에는 다른 프로젝트용 훅이나 개인 설정이 이미 들어있을 수 있습니다. 통째로 덮어쓰면 그런 설정이 날아가므로, setdefault로 없는 키만 만들고 중복 등록 여부를 체크하는 병합 로직을 짰습니다. 약간 번거롭긴 했지만 여러 컴퓨터에 반복 설치할 걸 생각하면 이 편이 안전합니다.
배운 점 및 개선점
정규식으로 비밀값을 마스킹하는 방식은 완벽하지 않다는 걸 인지하고 있습니다. 새로운 형태의 API 키가 나오면 패턴을 추가해야 하고, 패턴에 안 걸리는 값(예: 일반 비밀번호, 개인정보)은 그대로 새어나갈 수 있습니다. 일단은 흔히 쓰는 서비스들의 토큰 형식 위주로 커버했고, 필요하면 패턴을 계속 추가하는 방식으로 운영할 생각입니다.
또 하나는 tool_use/tool_result 블록을 완전히 제외한 게 맞는 선택인지 아직 확신이 서지 않습니다. 코드를 실제로 어떻게 고쳤는지(diff, 명령 실행 결과)가 빠지니 대화 맥락만 남고 실제 작업 내용은 유실됩니다. 나중에 필요하면 요약된 형태로라도 도구 호출 내역을 포함하는 옵션을 추가할 수 있을 것 같습니다.
다음 단계로는 실제로 여러 컴퓨터에 install.py를 돌려보면서 병합 로직이 예상대로 동작하는지 확인하고, LearningCollector가 이 export 파일들을 문제없이 집어가는지 end-to-end로 검증해볼 계획입니다.
참고 자료
- LearningCollector 저장소 (기존 export 포맷 참고)
- Claude Code Hooks 공식 문서 (SessionEnd 훅 스펙)