diff --git a/.claude/hooks/aptabase-accumulate.py b/.claude/hooks/aptabase-accumulate.py new file mode 100644 index 0000000..d18b8cb --- /dev/null +++ b/.claude/hooks/aptabase-accumulate.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +Stop 훅: 트랜스크립트의 새 assistant 메시지에서 usage 를 읽어 프로젝트 누적기에 더한다. + +- 누적 범위: /.claude/state/aptabase-accum.json +- 커밋 훅(aptabase-commit.py)이 flush 하고 0으로 리셋한다 +- 네트워크 호출 없음 (순수 파일 I/O) +""" +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from aptabase_common import ( # noqa: E402 + accumulate_from_transcript, + load_config, + load_state, + save_state, +) + + +def main() -> None: + if load_config() is None: + return + + try: + hook_input = json.load(sys.stdin) + except Exception: + return + + transcript_path = hook_input.get("transcript_path") or "" + if not transcript_path: + return + + cwd = hook_input.get("cwd") + state = load_state(cwd) + accumulate_from_transcript(state, transcript_path) + save_state(state, cwd) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/aptabase-accumulate.sh b/.claude/hooks/aptabase-accumulate.sh new file mode 100644 index 0000000..47ebe13 --- /dev/null +++ b/.claude/hooks/aptabase-accumulate.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Stop 훅: 직전 응답의 토큰 사용량을 프로젝트 누적기에 더한다. +# 실패해도 Claude 응답 흐름을 차단하지 않는다. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# __pycache__ 생성 방지 (import 시 .pyc 파일이 hooks 폴더에 생기는 것 방지) +export PYTHONDONTWRITEBYTECODE=1 +cat | python3 "$SCRIPT_DIR/aptabase-accumulate.py" 2>/dev/null || true +exit 0 diff --git a/.claude/hooks/aptabase-commit.py b/.claude/hooks/aptabase-commit.py new file mode 100644 index 0000000..7b68633 --- /dev/null +++ b/.claude/hooks/aptabase-commit.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +커밋 flush 훅: git HEAD 가 변했으면 누적 토큰을 Aptabase 로 전송한다. + +호출 경로 두 가지: + 1. Claude PostToolUse(Bash) 훅 → stdin 에 JSON 입력 + 2. git post-commit 훅 → stdin 비어 있음 (직접 git 에서 호출) + +두 경로 모두 같은 로직 (HEAD 비교 → 변했으면 flush → 리셋). + +전송 시점: + - 훅 호출 시마다 HEAD 확인 + - state.last_sent_commit 와 다르면 새 커밋으로 간주 → flush + +전송 후 동작: + - state.accum 을 0으로 리셋 + - state.last_sent_commit 를 현재 HEAD 로 갱신 + - 실패 시 state 유지 → 다음 호출에서 재시도 + +전송 필드: + - claude_oauth_id : Claude OAuth 이메일 + - plan : 구독 플랜 + - user_name : aptabase.json 에서 지정 + - local_ip : 로컬 IP + - public_ip : 공인 IP + - commit_hash : 현재 HEAD 해시 + - commit_message : git log -1 --pretty=%B + - issue_number : 커밋 메시지에서 추출 (없으면 null) + - repository : owner/repo 또는 디렉터리명 + - repository_url : remote.origin.url + - total_tokens : 누적 합계 + - input_tokens, cache_creation_tokens, cache_read_tokens, output_tokens +""" +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from aptabase_common import ( # noqa: E402 + EMPTY_TOTALS, + EVENT_NAME, + accumulate_from_transcript, + build_system_props, + consumed_tokens, + extract_issue_number, + get_claude_oauth_id, + get_local_ip, + get_plan, + get_public_ip, + get_repository_info, + load_config, + load_state, + make_aptabase_session_id, + now_iso, + post_event, + run_git, + save_state, + total_tokens, +) + + +def main() -> None: + cfg = load_config() + if cfg is None: + return + + # stdin 파싱: Claude 훅 모드(JSON) vs git post-commit 모드(비어 있음) + hook_input: dict = {} + try: + raw = sys.stdin.read() + if raw.strip(): + hook_input = json.loads(raw) + except Exception: + hook_input = {} + + # Claude 훅 모드면 Bash 툴에서 온 호출만 처리 (다른 툴은 무시) + # git 훅 모드면 hook_input 이 비어 있어서 이 체크를 통과한다 + if hook_input and hook_input.get("tool_name") != "Bash": + return + + cwd = hook_input.get("cwd") or os.getcwd() + + # git 저장소가 아니면 무시 + head = run_git(["rev-parse", "HEAD"], cwd=cwd) + if not head: + return + + state = load_state(cwd) + last_sent = state.get("last_sent_commit", "") + + if head == last_sent: + return + + if not last_sent: + # 첫 실행 — 기준점만 기록하고 다음 커밋을 기다린다 + state["last_sent_commit"] = head + save_state(state, cwd) + return + + # HEAD 가 바뀌었다 — Stop 훅이 놓쳤을 수 있는 최신 토큰을 flush. + # transcript_path 우선순위: + # 1) hook_input (Claude PostToolUse 훅 모드) + # 2) state.last_transcript (git post-commit 모드, Stop 훅이 저장한 값) + # + # 이 덕분에 한 턴에 여러 번 커밋하는 경우에도 각 커밋 직전까지 생성된 + # 토큰이 해당 커밋에 flush 된다 (턴 중간 커밋의 0 토큰 문제 해결). + transcript_path = ( + hook_input.get("transcript_path") + or state.get("last_transcript", "") + ) + if transcript_path: + accumulate_from_transcript(state, transcript_path) + + commit_message = run_git(["log", "-1", "--pretty=%B", head], cwd=cwd) + repo = get_repository_info(cwd) + accum = state.get("accum", dict(EMPTY_TOTALS)) + + payload = { + "timestamp": now_iso(), + "sessionId": make_aptabase_session_id(), + "eventName": EVENT_NAME, + "systemProps": build_system_props(), + "props": { + # 커밋 정보 + "commit_hash": head, + "commit_message": commit_message, + "issue_number": extract_issue_number(commit_message), + "repository": repo["name"] if not repo["url"] else repo["url"], + # 사용자 + "claude_oauth_id": get_claude_oauth_id(), + "plan": get_plan(), + "user_name": cfg.get("user_name", "unknown"), + # 네트워크 + "local_ip": get_local_ip(), + "public_ip": get_public_ip(), + # 토큰 (consumed 가 실제 쿼터 차감량, total 은 원시 합계) + "consumed_tokens": consumed_tokens(accum), + "total_tokens": total_tokens(accum), + "input_tokens": int(accum.get("input_tokens", 0)), + "output_tokens": int(accum.get("output_tokens", 0)), + "cache_creation_tokens": int(accum.get("cache_creation_tokens", 0)), + "cache_read_tokens": int(accum.get("cache_read_tokens", 0)), + }, + } + + ok = post_event(cfg["aptabase_host"], cfg["app_key"], payload) + if ok: + # 누적 리셋 + 전송 기준점 갱신 + state["accum"] = dict(EMPTY_TOTALS) + state["last_sent_commit"] = head + save_state(state, cwd) + # 실패: state 유지 → 다음 Bash 호출에서 재시도 (누적 + 새 커밋 병합) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/aptabase-commit.sh b/.claude/hooks/aptabase-commit.sh new file mode 100644 index 0000000..c43284d --- /dev/null +++ b/.claude/hooks/aptabase-commit.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# PostToolUse(Bash) / git post-commit 훅: git HEAD 가 바뀌었으면 누적 토큰을 Aptabase 로 flush. +# 실패해도 Claude 흐름을 차단하지 않는다. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# __pycache__ 생성 방지 (import 시 .pyc 파일이 hooks 폴더에 생기는 것 방지) +export PYTHONDONTWRITEBYTECODE=1 +cat | python3 "$SCRIPT_DIR/aptabase-commit.py" 2>/dev/null || true +exit 0 diff --git a/.claude/hooks/aptabase.json b/.claude/hooks/aptabase.json new file mode 100644 index 0000000..96511d1 --- /dev/null +++ b/.claude/hooks/aptabase.json @@ -0,0 +1,9 @@ +{ + "enabled": true, + "app_key": "A-SH-7756143445", + "aptabase_host": "https://aptabase.hmac.kr", + "user_name": "김민성(b16213)", + "git_repositories": [ + "D:/MYCLAUDE_PROJECT/recordingtest" + ] +} diff --git a/.claude/hooks/aptabase_common.py b/.claude/hooks/aptabase_common.py new file mode 100644 index 0000000..b7cef86 --- /dev/null +++ b/.claude/hooks/aptabase_common.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +""" +Aptabase 훅 공통 유틸. + +상태 파일: /.claude/state/aptabase-accum.json + - accum : 누적 토큰 (input/cache_creation/cache_read/output) + - offsets : 트랜스크립트별 읽은 byte offset + - last_sent_commit : 마지막으로 Aptabase 로 전송한 HEAD 커밋 해시 +""" +import json +import os +import platform +import random +import re +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +HOOK_DIR = Path(__file__).resolve().parent +CONFIG_PATH = HOOK_DIR / "aptabase.json" +# OAuth 정보는 ~/.claude.json (top-level) 에 저장됨. ~/.claude/config.json 은 API 키 캐시용. +CLAUDE_OAUTH_CONFIG = Path.home() / ".claude.json" +CLAUDE_API_CONFIG = Path.home() / ".claude" / "config.json" +SDK_VERSION = "claude-hook-aptabase@0.3.0" + +# Aptabase 이벤트 이름은 고정 — 커밋 단위 flush 하나뿐 +EVENT_NAME = "claude_commit" + +EMPTY_TOTALS = { + "input_tokens": 0, + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "output_tokens": 0, +} + +# ─── 쿼터 차감 가중치 ───────────────────────────────────────────────────── +# Anthropic 은 각 토큰 타입을 다른 요율로 쿼터에서 차감한다. +# "input 등가 토큰(Input-Equivalent Tokens)" = 실제 구독 한도에서 차감되는 양. +# +# 가중치 (모든 모델 공통, 2026-04 기준): +# input × 1.00 — 기본 기준 +# cache_creation × 1.25 — 캐시 쓰기 프리미엄 +# cache_read × 0.10 — 캐시 읽기 90% 할인 +# output × 5.00 — 출력이 입력의 5배 +TOKEN_WEIGHTS = { + "input_tokens": 1.00, + "cache_creation_tokens": 1.25, + "cache_read_tokens": 0.10, + "output_tokens": 5.00, +} + +# 커밋 메시지에서 이슈 번호 추출 — 가장 구체적인 패턴부터 +ISSUE_PATTERNS = [ + r"(?:FEAT|BUG|FIX|TASK|PROJ|ISSUE)[-\s](\d+)", + r"(?:GH|gh|issue|close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s#-]+(\d+)", + r"#(\d+)", +] + + +# ─── 설정 ───────────────────────────────────────────────────────────────── + +def load_config() -> dict | None: + try: + cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return None + if not cfg.get("enabled", True): + return None + if not cfg.get("app_key") or not cfg.get("aptabase_host"): + return None + return cfg + + +def _read_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def get_claude_oauth_config() -> dict: + """~/.claude.json — OAuth 로그인 / 구독 정보가 들어 있음.""" + return _read_json(CLAUDE_OAUTH_CONFIG) + + +def get_claude_api_config() -> dict: + """~/.claude/config.json — API 키 캐시 등 레거시 필드.""" + return _read_json(CLAUDE_API_CONFIG) + + +# ─── Claude / User 식별 ──────────────────────────────────────────────────── + +def get_claude_oauth_id() -> str: + """OAuth 로그인 이메일. 없으면 anonymous.""" + oauth = get_claude_oauth_config().get("oauthAccount") or {} + return oauth.get("emailAddress") or oauth.get("email") or "anonymous" + + +def get_plan() -> str: + """구독 플랜 식별자. + + 우선순위: + 1. oauthAccount.subscriptionType (max/pro/team/enterprise) + 2. oauthAccount.billingType (stripe_subscription / apple_subscription / ...) + 3. apikey (primaryApiKey 만 있는 경우) + 4. unknown + """ + oauth = get_claude_oauth_config().get("oauthAccount") or {} + plan = ( + oauth.get("subscriptionType") + or oauth.get("plan") + or oauth.get("billingType") + ) + if plan: + return str(plan) + if get_claude_api_config().get("primaryApiKey"): + return "apikey" + return "unknown" + + +# ─── 네트워크 ────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + finally: + s.close() + except Exception: + try: + return socket.gethostbyname(socket.gethostname()) + except Exception: + return "unknown" + + +def get_public_ip(timeout: float = 2.0) -> str: + for url in ("https://api.ipify.org", "https://ifconfig.me/ip"): + try: + req = urllib.request.Request(url, headers={"User-Agent": "claude-hook"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + continue + return "unknown" + + +# ─── Git ────────────────────────────────────────────────────────────────── + +def run_git(args: list, cwd: str | None = None) -> str: + try: + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=5, + encoding="utf-8", + errors="replace", + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return "" + + +def get_git_root(cwd: str | None = None) -> Path | None: + root = run_git(["rev-parse", "--show-toplevel"], cwd=cwd) + return Path(root) if root else None + + +def get_repository_info(cwd: str | None = None) -> dict: + """저장소 식별 정보. + + - remote.origin.url 이 있으면: 그 URL + owner/repo 추출 + - 없으면 (로컬 저장소): git root 의 절대 경로를 URL 자리에 넣고, 디렉터리명을 name 으로 + """ + url = run_git(["config", "--get", "remote.origin.url"], cwd=cwd) + root = get_git_root(cwd) + + name = "" + if url: + m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?/?$", url) + if m: + name = m.group(1) + if not name and root: + name = root.name + + # 로컬 저장소 (remote 미설정): 경로를 URL 대용으로 기록 + if not url and root: + url = str(root).replace("\\", "/") + + return {"name": name or "unknown", "url": url or ""} + + +def extract_issue_number(commit_message: str) -> str | None: + if not commit_message: + return None + for pattern in ISSUE_PATTERNS: + m = re.search(pattern, commit_message) + if m: + return m.group(1) + return None + + +# ─── 상태 파일 ──────────────────────────────────────────────────────────── + +def get_state_path(cwd: str | None = None) -> Path: + root = get_git_root(cwd) + base = root if root else Path(cwd or os.getcwd()) + return base / ".claude" / "state" / "aptabase-accum.json" + + +def load_state(cwd: str | None = None) -> dict: + path = get_state_path(cwd) + try: + state = json.loads(path.read_text(encoding="utf-8")) + except Exception: + state = {} + state.setdefault("accum", dict(EMPTY_TOTALS)) + for k in EMPTY_TOTALS: + state["accum"].setdefault(k, 0) + state.setdefault("offsets", {}) + state.setdefault("last_sent_commit", "") + # 최근 본 트랜스크립트 경로 — git post-commit 훅이 stdin 없이 호출될 때 + # fallback 으로 사용해서 턴 중간 커밋에서도 최신 토큰을 flush 할 수 있게 함 + state.setdefault("last_transcript", "") + return state + + +def save_state(state: dict, cwd: str | None = None) -> None: + path = get_state_path(cwd) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2), encoding="utf-8") + + +def total_tokens(accum: dict) -> int: + """원시 토큰 합계 (단순 합, 참고용).""" + return sum(int(accum.get(k, 0) or 0) for k in EMPTY_TOTALS) + + +def consumed_tokens(accum: dict) -> int: + """쿼터에서 실제 차감되는 "input 등가 토큰" 수. + + 각 토큰 타입에 가중치를 곱해 합산한다. 이 값이 구독 한도 소모를 + 가장 정확히 반영한다. + """ + return int(round(sum( + int(accum.get(k, 0) or 0) * w + for k, w in TOKEN_WEIGHTS.items() + ))) + + +# ─── 트랜스크립트 파싱 ──────────────────────────────────────────────────── + +def accumulate_from_transcript(state: dict, transcript_path: str) -> None: + """트랜스크립트의 새 영역(offset 이후)만 파싱해 state.accum 에 추가. + + append-only JSONL 이라 byte offset 기반이 안전하다. + """ + if not transcript_path or not os.path.exists(transcript_path): + return + + normalized = str(Path(transcript_path)) + offset = int(state["offsets"].get(normalized, 0)) + try: + size = os.path.getsize(transcript_path) + except OSError: + return + if size < offset: + # 파일이 교체됐을 수 있음 — 처음부터 다시 읽는다 + offset = 0 + if size == offset: + return + + accum = state["accum"] + try: + with open(transcript_path, "rb") as f: + f.seek(offset) + data = f.read(size - offset) + new_offset = f.tell() + except Exception: + return + + for line in data.splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except Exception: + continue + msg = entry.get("message") + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + usage = msg.get("usage") + if not isinstance(usage, dict): + continue + accum["input_tokens"] += int(usage.get("input_tokens", 0) or 0) + accum["cache_creation_tokens"] += int( + usage.get("cache_creation_input_tokens", 0) or 0 + ) + accum["cache_read_tokens"] += int( + usage.get("cache_read_input_tokens", 0) or 0 + ) + accum["output_tokens"] += int(usage.get("output_tokens", 0) or 0) + + state["offsets"][normalized] = new_offset + # git post-commit 훅 fallback 용으로 경로 저장 + state["last_transcript"] = normalized + + +# ─── Aptabase 전송 ──────────────────────────────────────────────────────── + +def make_aptabase_session_id() -> str: + """epoch_seconds + 8 random digits (Aptabase 권장 형식).""" + return f"{int(time.time())}{random.randint(0, 99999999):08d}" + + +def build_system_props() -> dict: + return { + "isDebug": False, + "locale": os.environ.get("LANG", "en-US").split(".")[0].replace("_", "-"), + "osName": platform.system(), + "osVersion": platform.release(), + "appVersion": "1.0.0", + "sdkVersion": SDK_VERSION, + } + + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def post_event(host: str, app_key: str, payload: dict) -> bool: + url = host.rstrip("/") + "/api/v0/event" + body = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "App-Key": app_key, + "User-Agent": f"ClaudeCodeHook/{SDK_VERSION}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + resp.read() + return True + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError): + return False diff --git a/.claude/hooks/install-git-hook.sh b/.claude/hooks/install-git-hook.sh new file mode 100644 index 0000000..24c2f54 --- /dev/null +++ b/.claude/hooks/install-git-hook.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# .git/hooks/post-commit 을 설치해서 모든 커밋(IDE / 터미널 / git GUI 포함)에서 +# aptabase-commit.sh 가 호출되도록 한다. +# +# 사용법: +# bash .claude/hooks/install-git-hook.sh # 설치 +# bash .claude/hooks/install-git-hook.sh --force # 기존 훅 덮어쓰기 +# bash .claude/hooks/install-git-hook.sh --uninstall +set -euo pipefail + +MARKER="# aptabase-commit-auto-hook" +FORCE=false +UNINSTALL=false + +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + --uninstall) UNINSTALL=true ;; + -h|--help) + sed -n '2,11p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; + esac +done + +GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "Error: not inside a git repository" >&2 + exit 1 +} + +HOOK_PATH="$GIT_ROOT/.git/hooks/post-commit" + +if [[ "$UNINSTALL" == "true" ]]; then + if [[ -f "$HOOK_PATH" ]] && grep -q "$MARKER" "$HOOK_PATH" 2>/dev/null; then + rm -f "$HOOK_PATH" + echo "Removed: $HOOK_PATH" + else + echo "Nothing to uninstall (no aptabase hook found at $HOOK_PATH)" + fi + exit 0 +fi + +# 기존 훅 체크 +if [[ -f "$HOOK_PATH" ]]; then + if grep -q "$MARKER" "$HOOK_PATH" 2>/dev/null; then + echo "Already installed: $HOOK_PATH" + exit 0 + fi + if [[ "$FORCE" != "true" ]]; then + echo "Error: existing post-commit hook found at $HOOK_PATH" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " 1. Re-run with --force to overwrite (backup will be saved as .bak)" >&2 + echo " 2. Manually append the following line to the existing hook:" >&2 + echo "" >&2 + echo " bash .claude/hooks/aptabase-commit.sh < /dev/null 2>/dev/null || true" >&2 + echo "" >&2 + exit 1 + fi + cp "$HOOK_PATH" "$HOOK_PATH.bak" + echo "Backed up existing hook to: $HOOK_PATH.bak" +fi + +mkdir -p "$(dirname "$HOOK_PATH")" +cat > "$HOOK_PATH" <<'EOF' +#!/usr/bin/env bash +# aptabase-commit-auto-hook +# Auto-installed by .claude/hooks/install-git-hook.sh +# 모든 커밋 후 aptabase-commit.sh 를 호출해 누적 토큰을 flush 한다. +set -uo pipefail + +PROJECT_HOOK="$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/aptabase-commit.sh" +if [[ -f "$PROJECT_HOOK" ]]; then + bash "$PROJECT_HOOK" < /dev/null >/dev/null 2>&1 || true +fi +exit 0 +EOF +chmod +x "$HOOK_PATH" + +echo "Installed: $HOOK_PATH" +echo "" +echo "이제 IDE, 터미널, git GUI 에서 커밋해도 aptabase 에 누적 토큰이 전송됩니다." diff --git a/.claude/hooks/usage.md b/.claude/hooks/usage.md new file mode 100644 index 0000000..29d2a73 --- /dev/null +++ b/.claude/hooks/usage.md @@ -0,0 +1,295 @@ +# common/.claude/hooks 사용법 + +Claude 의 모든 응답 토큰을 프로젝트 단위로 누적하고, git 커밋이 일어나면 **Aptabase** 로 한 번에 전송하는 훅 묶음. + +--- + +## 파일 구성 + +``` +.claude/hooks/ +├── aptabase.json # 설정 (app_key, aptabase_host, user_name, enabled) +├── aptabase_common.py # 공통 유틸 (설정/IP/git/state/transcript/POST) +├── aptabase-accumulate.sh # Stop 훅 진입점 +├── aptabase-accumulate.py # 트랜스크립트 → state.accum 누적 (네트워크 없음) +├── aptabase-commit.sh # 커밋 flush 진입점 (Claude 훅 + git 훅 공용) +├── aptabase-commit.py # HEAD 변화 감지 → POST → 리셋 +└── install-git-hook.sh # .git/hooks/post-commit 설치 스크립트 + +.claude/state/ +└── aptabase-accum.json # 누적 상태 (자동 생성, git root 기준) +``` + +--- + +## 설치 (프로젝트 1회) + +### 1. 훅 파일 복사 + +```bash +# 프로젝트 루트에서 +cp -r path/to/common/.claude/hooks .claude/hooks +``` + +### 2. aptabase.json 채우기 + +`.claude/hooks/aptabase.json`: + +```json +{ + "enabled": true, + "app_key": "A-SH-XXXXXXXXXX", + "aptabase_host": "https://aptabase.example.com", + "user_name": "kim" +} +``` + +| 키 | 설명 | +|---|---| +| `enabled` | false 면 모든 훅이 즉시 종료 (일시 비활성) | +| `app_key` | Aptabase App Key (self-hosted 는 `A-SH-` 접두사) | +| `aptabase_host` | Aptabase 인스턴스 base URL | +| `user_name` | props.user_name 으로 전송할 사용자 식별자 | + +### 3. settings.json 에 Claude 훅 등록 + +`.claude/settings.json`: + +```json +{ + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/aptabase-accumulate.sh" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/aptabase-commit.sh" } + ] + } + ] + } +} +``` + +기존 훅이 있으면 같은 `hooks` 배열에 추가. + +### 4. git post-commit 훅 설치 (필수) + +**Claude 외부**(IDE, 터미널, git GUI)에서 이루어진 커밋도 포착하려면 반드시 실행: + +```bash +bash .claude/hooks/install-git-hook.sh +``` + +옵션: +```bash +bash .claude/hooks/install-git-hook.sh --force # 기존 post-commit 을 .bak 로 백업 후 덮어쓰기 +bash .claude/hooks/install-git-hook.sh --uninstall # 제거 +``` + +> `.git/hooks/` 는 버전 관리되지 않으므로 **clone 할 때마다 재설치 필요**. + +--- + +## 동작 흐름 + +``` +Claude 응답 완료 ──┐ + │ + ▼ + [aptabase-accumulate.sh] + └─ 트랜스크립트 JSONL 의 새 byte 영역만 파싱 + └─ assistant 메시지의 usage 를 state.accum 에 누적 + └─ 네트워크 호출 없음 + +git commit (Claude 의 Bash 툴 또는 외부 도구) ──┐ + │ + ┌─────────────────────────┴────────┐ + ▼ ▼ + [PostToolUse(Bash)] [.git/hooks/post-commit] + └─ aptabase-commit.sh └─ aptabase-commit.sh + │ │ + └──────────────┬───────────────────┘ + ▼ + [aptabase-commit.py] + ├─ git rev-parse HEAD + ├─ last_sent_commit 과 같으면 return (중복 방지) + ├─ 다르면: + │ ├─ 최신 트랜스크립트 flush (Claude 훅 모드) + │ ├─ commit_message / issue_number / repository 수집 + │ ├─ claude_oauth_id / plan / local_ip / public_ip 수집 + │ ├─ POST {aptabase_host}/api/v0/event + │ ├─ 성공: state.accum = 0, last_sent_commit = HEAD + │ └─ 실패: state 유지, 다음 호출에서 재시도 +``` + +--- + +## 전송되는 이벤트 + +**이벤트 이름:** `claude_commit` (고정) + +### props 필드 + +| 필드 | 출처 | +|---|---| +| `claude_oauth_id` | `~/.claude/config.json` 의 OAuth 이메일 (없으면 `anonymous`) | +| `plan` | 구독 플랜 (`max` / `pro` / `team` / `enterprise` / `apikey` / `unknown`) | +| `user_name` | `aptabase.json` 의 `user_name` | +| `local_ip` | UDP connect 트릭 (패킷 미전송) | +| `public_ip` | `api.ipify.org` → `ifconfig.me` (2초 타임아웃) | +| `commit_hash` | `git rev-parse HEAD` | +| `commit_message` | `git log -1 --pretty=%B` | +| `issue_number` | 커밋 메시지에서 regex 추출. 없으면 `null` | +| `repository` | `owner/repo` (remote URL) 또는 디렉터리명 | +| `repository_url` | `git config --get remote.origin.url` | +| `total_tokens` | 누적 합계 | +| `input_tokens`, `cache_creation_tokens`, `cache_read_tokens`, `output_tokens` | 누적 세부 | + +**이슈 번호 추출 패턴** (우선순위 순): +- `FEAT-123`, `BUG-45`, `FIX-7`, `TASK-9`, `PROJ-100`, `ISSUE-12` +- `closes #45`, `fixes #12`, `resolves #3`, `GH-8` +- `#123` + +--- + +## 검증 + +### 1. Aptabase 도달성 확인 (curl) + +```bash +APP_KEY=$(jq -r .app_key .claude/hooks/aptabase.json) +HOST=$(jq -r .aptabase_host .claude/hooks/aptabase.json) +curl -i -X POST "$HOST/api/v0/event" \ + -H "Content-Type: application/json" \ + -H "App-Key: $APP_KEY" \ + -H "User-Agent: ClaudeCodeHook/test" \ + -d '{ + "timestamp":"2026-04-07T00:00:00.000Z", + "sessionId":"manual-test", + "eventName":"claude_commit", + "systemProps":{"osName":"Test","sdkVersion":"manual"}, + "props":{"commit_message":"manual test","total_tokens":1} + }' +``` + +`HTTP/1.1 200` + `{}` 이면 성공. + +### 2. Stop 훅 누적 확인 + +Claude 와 대화 후: +```bash +cat .claude/state/aptabase-accum.json +``` + +`accum.input_tokens`, `output_tokens` 등이 0 이 아니면 정상 동작. + +### 3. 커밋 훅 수동 실행 + +```bash +bash .claude/hooks/aptabase-commit.sh < /dev/null +``` + +Aptabase 대시보드에서 이벤트 확인 → `accum` 이 0 으로 리셋되면 성공. + +### 4. E2E 테스트 + +```bash +# 1. Claude 와 대화 → 토큰 누적 +# 2. cat .claude/state/aptabase-accum.json (accum 비어있지 않음 확인) +git commit --allow-empty -m "test: aptabase hook #999" +# 3. Aptabase 대시보드에서 claude_commit 이벤트 확인 +# 4. cat .claude/state/aptabase-accum.json (accum 리셋 확인) +``` + +--- + +## 누적 상태 파일 + +`/.claude/state/aptabase-accum.json`: + +```json +{ + "accum": { + "input_tokens": 1234, + "cache_creation_tokens": 5678, + "cache_read_tokens": 9012, + "output_tokens": 345 + }, + "offsets": { + "C:\\Users\\...\\projects\\d--foo\\session-uuid.jsonl": 45678 + }, + "last_sent_commit": "abc123def..." +} +``` + +- `accum`: 현재 누적 중인 토큰 (커밋 시 리셋됨) +- `offsets`: 트랜스크립트 JSONL 별로 이미 읽은 byte 위치 (append-only 특성 덕에 중복 집계 방지) +- `last_sent_commit`: 마지막으로 Aptabase 에 전송한 HEAD 해시 (중복 전송 방지) + +**수동 리셋:** `rm .claude/state/aptabase-accum.json` + +--- + +## 엣지 케이스 + +| 상황 | 동작 | +|---|---| +| 첫 설치 후 첫 Bash 호출 | `last_sent_commit` 가 빈 상태 → 현재 HEAD 를 기준점으로 기록만. 전송 안 함 | +| 네트워크 실패 | `state` 유지. 다음 호출에서 재시도 (누적값 + 새 커밋 병합) | +| Claude 가 Bash 로 커밋 | `PostToolUse(Bash)` 와 `.git/hooks/post-commit` 둘 다 발동. `last_sent_commit` 덕에 한 번만 전송 | +| 외부(IDE)에서 커밋 | `.git/hooks/post-commit` 만 발동 | +| git 저장소 밖에서 실행 | `git rev-parse HEAD` 실패 → 조용히 종료 | +| OAuth 로그인 안 됨 (API 키) | `claude_oauth_id = "anonymous"`, `plan = "apikey"` | +| `amend`, `rebase`, `cherry-pick` | HEAD 해시가 바뀌므로 모두 포착 | + +--- + +## 트러블슈팅 + +**누적이 안 쌓임 (`accum` 이 계속 0)** +1. `aptabase.json` 의 `enabled: true` +2. `app_key`, `aptabase_host` 실제 값인지 +3. settings.json 의 `Stop` 훅에 `aptabase-accumulate.sh` 등록되어 있는지 +4. `transcript_path` 가 훅 입력 JSON 에 실제로 있는지 (Claude Code 버전 확인) +5. 해당 트랜스크립트 파일 읽기 권한 + +**커밋했는데 Aptabase 에 안 뜸** +1. settings.json 의 `PostToolUse(Bash)` 훅 등록 확인 +2. `.git/hooks/post-commit` 이 실제로 있는지: `cat .git/hooks/post-commit` +3. `last_sent_commit` 이 이미 현재 HEAD 와 같은지 (이미 전송됨) +4. curl 로 직접 POST 했을 때 200 이 오는지 (네트워크/인증 분리 검증) +5. `python3 --version` 동작 +6. git 저장소 안에서 실행 중인지 + +**첫 커밋이 무시됨** +의도된 동작. 첫 실행 시 `last_sent_commit` 를 현재 HEAD 로 기록하고 종료. 다음 커밋부터 전송. + +**전송 기준점 초기화** (디버깅용) +```bash +jq '.last_sent_commit = ""' .claude/state/aptabase-accum.json > /tmp/_s && mv /tmp/_s .claude/state/aptabase-accum.json +``` + +**일시 비활성** +`aptabase.json` 의 `enabled` 를 `false` 로. 훅 등록은 유지해도 된다. + +--- + +## 제거 + +```bash +# 1. git post-commit 훅 제거 +bash .claude/hooks/install-git-hook.sh --uninstall + +# 2. settings.json 에서 Stop / PostToolUse(Bash) 훅 엔트리 제거 + +# 3. 파일 삭제 (선택) +rm -rf .claude/hooks .claude/state/aptabase-accum.json +``` diff --git a/.claude/settings.json b/.claude/settings.json index 08ba909..e83cb77 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,6 +51,26 @@ "command": "bash .claude/hooks/stop-handoff-reminder.sh" } ] + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/aptabase-accumulate.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/aptabase-commit.sh" + } + ] } ] } diff --git a/.claude/skills/aptabase/SKILL.md b/.claude/skills/aptabase/SKILL.md new file mode 100644 index 0000000..93e9509 --- /dev/null +++ b/.claude/skills/aptabase/SKILL.md @@ -0,0 +1,229 @@ +--- +name: aptabase +description: Aptabase 커밋 기준 토큰 기록 훅을 설치/검증/디버깅한다. 사용자가 "aptabase 설정", "aptabase 테스트", "토큰 기록 확인", "누적 토큰"을 요청할 때 사용. +--- + +# Aptabase 커밋 기준 토큰 기록 + +Claude 의 모든 응답 토큰을 프로젝트 단위로 누적하고, **git 커밋이 발생하면** Aptabase 로 한 번에 flush 한 뒤 0 으로 리셋한다. + +## 구조 + +``` +.claude/hooks/ +├── aptabase.json # 설정 (app_key, aptabase_host, user_name) +├── aptabase_common.py # 공통 유틸 (Python) +├── aptabase-accumulate.sh # Stop 훅 진입점 +├── aptabase-accumulate.py # Stop 로직 — 토큰 누적만 +├── aptabase-commit.sh # 커밋 flush 진입점 (Claude 훅 + git 훅 공용) +├── aptabase-commit.py # 커밋 감지 + Aptabase POST + 리셋 +└── install-git-hook.sh # .git/hooks/post-commit 설치 스크립트 + +.claude/state/ +└── aptabase-accum.json # 누적 상태 (자동 생성, git root 기준) + +.git/hooks/ +└── post-commit # install-git-hook.sh 가 설치 (모든 커밋 포착) +``` + +## 동작 방식 + +| 시점 | 훅 | 하는 일 | +|---|---|---| +| 응답 완료 | `Stop` → `aptabase-accumulate.sh` | 트랜스크립트 새 영역 파싱 → `state.accum` 에 토큰 더하기 (네트워크 없음) | +| Claude 의 Bash 툴 실행 후 | `PostToolUse(Bash)` → `aptabase-commit.sh` | HEAD 변화 감지. 바뀌었으면 flush → POST → 리셋 | +| **모든 커밋** (IDE / 터미널 / GUI) | `.git/hooks/post-commit` → `aptabase-commit.sh` | 같은 로직. Claude 밖에서 이루어진 커밋도 포착 | + +`aptabase-commit.py` 는 stdin 에 JSON 이 있으면 Claude 훅 모드, 비어 있으면 git 훅 모드로 동작한다. 두 경로가 중복 실행돼도 `last_sent_commit` 비교 덕에 이중 전송되지 않는다. + +- **누적 범위**: `/.claude/state/aptabase-accum.json` — 프로젝트(= git 저장소)마다 독립 +- **커밋 감지**: HEAD 해시 변화 기반 — `git commit`, `--amend`, `merge`, `cherry-pick`, `rebase` 모두 감지 +- **첫 실행**: 기존 HEAD 를 `last_sent_commit` 에 기준점으로 기록만 하고 전송 안 함 (이전 작업 토큰이 0이라) +- **전송 실패**: `state` 유지 → 다음 Bash 호출에서 재시도 (누적값 + 새 커밋 병합) + +## 1. 설정 + +`.claude/hooks/aptabase.json`: + +```json +{ + "enabled": true, + "app_key": "A-SH-XXXXXXXXXX", + "aptabase_host": "https://aptabase.example.com", + "user_name": "kim" +} +``` + +| 키 | 의미 | +|---|---| +| `enabled` | false 로 두면 훅이 즉시 종료 (일시 비활성) | +| `app_key` | Aptabase App Key (self-hosted 는 `A-SH-` 접두사) | +| `aptabase_host` | Aptabase 인스턴스 base URL | +| `user_name` | props.user_name 으로 전송할 사용자 식별자 | + +## 2. settings.json 에 훅 등록 + +`.claude/settings.json` 의 `hooks` 에 두 항목 모두 등록: + +```json +{ + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/aptabase-accumulate.sh" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "bash .claude/hooks/aptabase-commit.sh" } + ] + } + ] + } +} +``` + +이미 다른 Stop / PostToolUse 훅이 있으면 같은 `hooks` 배열에 추가한다. + +## 2-1. git post-commit 훅 설치 (필수) + +Claude 외부에서 이루어진 커밋(IDE, 터미널, git GUI 등)도 포착하려면 `.git/hooks/post-commit` 을 설치해야 한다. 프로젝트 루트에서 한 번만 실행: + +```bash +bash .claude/hooks/install-git-hook.sh +``` + +이미 `.git/hooks/post-commit` 이 존재하면 에러가 나면서 병합 방법을 안내한다. 기존 훅을 덮어쓰려면: + +```bash +bash .claude/hooks/install-git-hook.sh --force # 기존 훅을 .bak 로 백업하고 덮어쓰기 +``` + +제거: + +```bash +bash .claude/hooks/install-git-hook.sh --uninstall +``` + +> `.git/hooks/` 는 버전 관리되지 않으므로 **clone 할 때마다 재설치 필요**하다. +> CI/자동 설정 스크립트에서 프로젝트 초기화 시 함께 실행하는 것을 권장한다. + +## 3. 전송되는 payload + +Aptabase 이벤트 이름은 **`claude_commit`** (고정). + +### systemProps +- `osName`, `osVersion`, `locale`, `appVersion`, `sdkVersion`, `isDebug` + +### props +| 필드 | 출처 | +|---|---| +| `claude_oauth_id` | `~/.claude/config.json` 의 `oauthAccount.emailAddress` (없으면 `anonymous`) | +| `plan` | `oauthAccount.subscriptionType` (max/pro/team/enterprise) — API 키면 `apikey` | +| `user_name` | `aptabase.json` 의 `user_name` | +| `local_ip` | UDP connect 트릭으로 추출 (패킷 미전송) | +| `public_ip` | `api.ipify.org` / `ifconfig.me` (2초 타임아웃) | +| `commit_hash` | `git rev-parse HEAD` | +| `commit_message` | `git log -1 --pretty=%B` | +| `issue_number` | 커밋 메시지에서 regex 추출 (`FEAT-123`, `#123`, `fixes #45` 등). 없으면 `null` | +| `repository` | `owner/repo` (remote URL) 또는 디렉터리명 | +| `repository_url` | `git config --get remote.origin.url` | +| `total_tokens` | 누적 합계 | +| `input_tokens`, `cache_creation_tokens`, `cache_read_tokens`, `output_tokens` | 누적 세부 | + +> Aptabase 최상위 `sessionId` 는 epoch+random 으로 매 이벤트 새로 생성된다. +> Claude 세션 UUID 는 사용하지 않는다. + +## 4. 연결 테스트 + +**단계 1 — Aptabase 도달 가능성 확인 (curl):** + +```bash +APP_KEY=$(jq -r .app_key .claude/hooks/aptabase.json) +HOST=$(jq -r .aptabase_host .claude/hooks/aptabase.json) +curl -i -X POST "$HOST/api/v0/event" \ + -H "Content-Type: application/json" \ + -H "App-Key: $APP_KEY" \ + -H "User-Agent: ClaudeCodeHook/test" \ + -d '{ + "timestamp":"2026-04-07T00:00:00.000Z", + "sessionId":"manual-test", + "eventName":"claude_commit", + "systemProps":{"osName":"Test","sdkVersion":"manual"}, + "props":{"commit_message":"manual test","total_tokens":1} + }' +``` + +200 + `{}` 이면 Aptabase 수신 OK. + +**단계 2 — 훅 직접 실행 (Stop 누적):** + +```bash +echo '{"transcript_path":"","cwd":"'"$PWD"'","hook_event_name":"Stop"}' \ + | bash .claude/hooks/aptabase-accumulate.sh +cat .claude/state/aptabase-accum.json +``` + +transcript_path 가 비어있으면 accum 은 0 그대로지만, state 파일은 생성돼야 한다. + +**단계 3 — 실제 동작 확인:** + +1. Claude 와 대화해서 토큰 쌓기 +2. `cat .claude/state/aptabase-accum.json` 으로 누적 확인 +3. `git commit` 실행 +4. Aptabase 대시보드에서 `claude_commit` 이벤트 확인 +5. `cat .claude/state/aptabase-accum.json` — `accum` 이 0 으로 리셋, `last_sent_commit` 갱신됐는지 확인 + +## 5. 누적 상태 파일 포맷 + +`/.claude/state/aptabase-accum.json`: + +```json +{ + "accum": { + "input_tokens": 1234, + "cache_creation_tokens": 5678, + "cache_read_tokens": 9012, + "output_tokens": 345 + }, + "offsets": { + "C:\\Users\\...\\projects\\d--foo\\session-uuid.jsonl": 45678 + }, + "last_sent_commit": "abc123def..." +} +``` + +- `offsets`: 트랜스크립트 JSONL 파일별로 이미 읽은 byte 위치. append-only 특성 덕에 중복 집계 방지 +- 수동 리셋이 필요하면 `rm .claude/state/aptabase-accum.json` + +## 6. 트러블슈팅 + +**누적이 안 쌓임 (`accum` 이 계속 0):** +1. `aptabase.json` 의 `enabled: true` +2. `app_key`, `aptabase_host` 실제 값 +3. settings.json 의 Stop 훅에 `aptabase-accumulate.sh` 등록 +4. `transcript_path` 가 훅 입력 JSON 에 실제로 있는지 +5. 해당 파일 읽기 권한 + +**커밋했는데 Aptabase 에 안 뜸:** +1. settings.json 의 PostToolUse(Bash) 훅에 `aptabase-commit.sh` 등록 +2. `last_sent_commit` 가 이미 현재 HEAD 와 같은지 (이미 전송된 상태) +3. curl 로 직접 POST 했을 때 200 이 오는지 (네트워크/인증 분리 검증) +4. `python3 --version` 동작 +5. git 저장소 안에서 동작 중인지 (`git rev-parse HEAD` 성공해야 함) + +**첫 커밋이 무시됨:** +의도된 동작. `last_sent_commit` 가 빈 상태일 때는 현재 HEAD 를 기준점으로 기록만 하고 전송하지 않는다. 다음 커밋부터 전송된다. + +**전송 기준점 초기화:** +```bash +jq '.last_sent_commit = ""' .claude/state/aptabase-accum.json > /tmp/_s && mv /tmp/_s .claude/state/aptabase-accum.json +``` + +**일시 비활성:** +`aptabase.json` 에서 `enabled: false`. 훅 등록은 유지해도 된다. diff --git a/EG-BIMModeler_Source/MainWindow.xaml b/EG-BIMModeler_Source/MainWindow.xaml new file mode 100644 index 0000000..d8281d1 --- /dev/null +++ b/EG-BIMModeler_Source/MainWindow.xaml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +