commit 과 push test

This commit is contained in:
minsung
2026-04-07 20:35:45 +09:00
parent de0ca9876a
commit b20ec32c36
12 changed files with 1826 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""
Stop 훅: 트랜스크립트의 새 assistant 메시지에서 usage 를 읽어 프로젝트 누적기에 더한다.
- 누적 범위: <git-root>/.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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
"""
Aptabase 훅 공통 유틸.
상태 파일: <git-root>/.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

View File

@@ -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 에 누적 토큰이 전송됩니다."

295
.claude/hooks/usage.md Normal file
View File

@@ -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 리셋 확인)
```
---
## 누적 상태 파일
`<git-root>/.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
```

View File

@@ -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"
}
]
}
]
}

View File

@@ -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` 비교 덕에 이중 전송되지 않는다.
- **누적 범위**: `<git-root>/.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. 누적 상태 파일 포맷
`<git-root>/.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`. 훅 등록은 유지해도 된다.

View File

@@ -0,0 +1,165 @@
<mah:MetroWindow x:Class="EGModeler.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:eg="http://hanmaceng.co.kr/HmEG"
xmlns:cb="clr-namespace:Editor.CommandControl;assembly=Editor04.CommandControl"
xmlns:local="clr-namespace:EGModeler"
xmlns:v="clr-namespace:EditorCore.View;assembly=EditorCore"
xmlns:control="clr-namespace:EGModeler.Controls"
xmlns:corecontrol="clr-namespace:EditorCore.Controls;assembly=EditorCore"
xmlns:commonUI="clr-namespace:HmCommonUI;assembly=HmCommonUI"
xmlns:vm="clr-namespace:EGModeler.ViewModels"
xmlns:enums="clr-namespace:EditorCore.Enums;assembly=EditorCore"
xmlns:localization="clr-namespace:Editor.Localization;assembly=Editor01.Localization"
MinWidth="1345" MinHeight="600" d:Width="1920" d:Height="1080" Width="1280" Height="720"
mc:Ignorable="d" WindowState="{Binding WindowState}" mah:DialogParticipation.Register="{Binding}"
ShowTitleBar="True" ShowMinButton="False" ShowCloseButton="False" ShowMaxRestoreButton="False"
Title="EGModeler" Name="root" Background="Transparent" Loaded="MainWindow_Loaded">
<mah:MetroWindow.TitleTemplate>
<DataTemplate>
<control:ModelerUpperBar x:Name="titleBar" Loaded="titleBar_Loaded"/>
</DataTemplate>
</mah:MetroWindow.TitleTemplate>
<mah:MetroWindow.Resources>
<local:BoolToParameterConverter x:Key="BoolToParameterConverter"/>
<Style TargetType="commonUI:StrokeText" BasedOn="{StaticResource {x:Type commonUI:StrokeText}}">
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</Style.Triggers>
</Style>
</mah:MetroWindow.Resources>
<Grid x:Name="MainWindowContentGrid">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="3"/>
<RowDefinition Height="0" MaxHeight="400"/>
<RowDefinition Height="70"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="75"/>
<ColumnDefinition Width="315"/>
<ColumnDefinition MinWidth="200" Width="*"/>
<ColumnDefinition Width="2"/>
<ColumnDefinition MinWidth="200" Width="*"/>
<ColumnDefinition Width="75"/>
</Grid.ColumnDefinitions>
<localization:DummyControl Language="ko_KR"/>
<Grid x:Name="MainViewportGrid" Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" Grid.ColumnSpan="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=2}" />
<ColumnDefinition Width="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=380}" MinWidth="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=380}" MaxWidth="1000"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<control:LeftViewportPanel AllowDrop="True" DragEnter="ViewPortPanel_DragEnter" Drop="ViewPortPanel_Drop" />
<corecontrol:PropertySummaryBar VerticalAlignment="Top" HorizontalAlignment="Left" Visibility="Hidden" Margin="330 0 0 0"/>
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom" HorizontalAlignment="Left">
<Border CornerRadius="0 5 5 0" Background="#ECE4D2" BorderBrush="Black" BorderThickness="1" Visibility="{Binding IsLeftSidePanelVisible, Converter={StaticResource boolToVisibilityConverter}}">
<StackPanel Width="140">
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Bottom" IsEnabled="{Binding IsUsingDefaultSelectFilter}" Margin="0 2">
<commonUI:StrokeText Fill="{DynamicResource SolidColorBrushTable.Type4.Normal.Foreground}" FontWeight="SemiBold" Text="{DynamicResource Control@SELECTIONFILTER}" Margin="2 5 2 2"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@POINT}" IsChecked="{Binding Point, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@LINE}" IsChecked="{Binding Curve, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@MESH}" IsChecked="{Binding Mesh, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@DIMENSION}" IsChecked="{Binding Dimension, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@TEXT}" IsChecked="{Binding Text, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@HATCH}" IsEnabled="False"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@BLOCK}" IsChecked="{Binding Block, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@GROUP}" IsEnabled="False"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@BILLBOARD}" IsChecked="{Binding Billboard, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@CONTROL_POINT}" IsChecked="{Binding ControlPoint, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@VERTEX}" IsChecked="{Binding TopologyVertex, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@EDGE}" IsChecked="{Binding TopologyEdge, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@FACE}" IsChecked="{Binding TopologyFace, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@OTHER}" IsChecked="{Binding Other, Mode=TwoWay}"/>
</StackPanel>
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Bottom" IsEnabled="{Binding IsOsnap}" Margin="0 2">
<commonUI:StrokeText Fill="{DynamicResource SolidColorBrushTable.Type4.Normal.Foreground}" FontWeight="SemiBold" Text="{DynamicResource Control@OSNAP}" Margin="2 5 2 2"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@POINT}" IsChecked="{Binding PointOsnap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@NEAR_POINT}" IsChecked="{Binding NearOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@END_POINT}" IsChecked="{Binding EndOsnap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@MID_POINT}" IsChecked="{Binding MidpointOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@QUADRANT_POINT}" IsChecked="{Binding QuadrantOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@CENTER_POINT}" IsChecked="{Binding CenterOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@TANGENT_POINT}" IsChecked="{Binding TangentOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@VERTEX}" IsChecked="{Binding VertexOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@PERPENDICULAR_POINT}" IsChecked="{Binding PerpendicularOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@EXTENSION}" IsEnabled="False"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@INTERSECTION}" IsChecked="{Binding IntersectionOsnap, Mode=TwoWay}"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@KNOT_POINT}" IsChecked="{Binding KnotOsnap, Mode=TwoWay}" IsEnabled="False"/>
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@PROJECTION_POINT}" IsChecked="{Binding ProjectionOsnap, Mode=TwoWay}"/>
</StackPanel>
</StackPanel>
</Border>
<corecontrol:FlipToggleButton HorizontalAlignment="Left" VerticalAlignment="Bottom" IsChecked="{Binding IsLeftSidePanelVisible, Mode=TwoWay}" IsLeftFlip="True" Margin="-1 0 0 10"/>
</StackPanel>
<Grid HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="15 5 0 5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="330"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Visibility="{Binding CursorCoordinateDisplay, Converter={StaticResource boolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<commonUI:StrokeText Grid.Column="0" Text="{Binding MousePosX}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
<commonUI:StrokeText Grid.Column="1" Text="{Binding MousePosY}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
<commonUI:StrokeText Grid.Column="2" Text="{Binding MousePosZ}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
</Grid>
<StackPanel Grid.Column="3" Margin="0 0 10 0">
<commonUI:StrokeText Text="{Binding ShowCoordOpt}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" Visibility="{Binding CursorCoordinateDisplay, Converter={StaticResource boolToVisibilityConverter}}"/>
<commonUI:StrokeText Text="{Binding CurrentUnitStr}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold"/>
</StackPanel>
</Grid>
</Grid>
<corecontrol:FlipToggleButton HorizontalAlignment="Right" IsChecked="{Binding IsSidePanelVisible, Mode=TwoWay}" Margin="0 0 -1 0"/>
<GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False"/>
<v:SidePanel Grid.Column="2" Visibility="{Binding IsSidePanelVisible, Converter={StaticResource boolToVisibilityConverter}}"/>
</Grid>
<GridSplitter Grid.Row="1" Grid.Column="2" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False" MouseDoubleClick="GridSplitter_MouseDoubleClick"/>
<Button Grid.Row="3" Grid.Column="0" HorizontalAlignment="Right" Style="{DynamicResource LowerExpandButtonButtonStyle}" IsDefault="True" Content="{DynamicResource Control@SETTINGS}" Command="{Binding SeeAllCommands}" CommandParameter="{x:Static enums:AllCommandsWindowCategory.Settings}"/>
<Grid Grid.Row="3" Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="0" Focusable="False" Content="{DynamicResource Control@OSNAP}" IsChecked="{Binding IsOsnap}" Kind="Object_Snap" />
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="1" Focusable="False" Content="{DynamicResource Control@GRIDSNAP}" IsChecked="{Binding GridSnap}" Kind="Grid_Snap" />
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="2" Focusable="False" Content="{DynamicResource Control@SNAP_TRACE}" IsChecked="{Binding SnapTrace}" Kind="Custom" PathData="{DynamicResource Object_Trace_DrawingImage}"/>
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="0" Focusable="False" Content="{DynamicResource Control@ORTHOMODE}" IsChecked="{Binding OrthoMode}" Kind="Orthogonal" />
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="1" Focusable="False" Content="{DynamicResource Control@PLANARMODE}" IsChecked="{Binding PlanarMode}" Kind="Orthogonal" />
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="2" Focusable="False" Content="{DynamicResource Control@SELECTIONFILTER}" IsChecked="{Binding IsUsingDefaultSelectFilter}" Kind="Visual_Effect" />
</Grid>
<cb:CommandPanel Grid.Row="2" Grid.Column="2" Grid.RowSpan="2" SizeChanged="CommandPanel_SizeChanged" InstanceIdx="0"/>
<GridSplitter Grid.Row="3" Grid.Column="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False"/>
<control:CommandTabPanel Grid.Row="3" Grid.Column="4"/>
<Button Grid.Row="3" Grid.Column="5" HorizontalAlignment="Right" Style="{DynamicResource LowerExpandButtonButtonStyle}" IsDefault="True" Content="{DynamicResource Control@SEEALLCOMMANDS}" Command="{Binding SeeAllCommands}" CommandParameter="{x:Static enums:AllCommandsWindowCategory.Create}"/>
<Grid x:Name="WidzetCanvas" Grid.ColumnSpan="100" Grid.RowSpan="100"/>
<Grid x:Name="settingOptionPanel" HorizontalAlignment="Right" VerticalAlignment="Top" Grid.Column="4" Grid.ColumnSpan="2" Height="100" Width="408"/>
</Grid>
</mah:MetroWindow>

View File

@@ -0,0 +1,441 @@
using Editor.AppManager;
using Editor.CommandControl.ViewModel;
using Editor.CommandCore;
using Editor.CommandCustom;
using Editor.PluginInterface;
using Editor.WidzetPluginInterface;
using EditorCore;
using EGModeler.Controls;
using EGModeler.ViewModels;
using HmEG;
using HmEG.Controls;
using MahApps.Metro.Controls;
using System.Diagnostics;
using System;
using System.Globalization;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
namespace EGModeler
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : MetroWindow
{
private ECommandManager ECommandManager { get => ECommandManager.GetCommandManager(0); }
private SnapAssist? _snap;
public MainWindow()
{
//테마 테스트
//ThemeManager.Instance.SwitchTheme(ThemeType.ColorThemeBlack);
InitializeComponent();
MainWindowLoadingHelper.Instance.IsInitialized = true;
this.DataContext = MainWindowViewModel.Instance;
WidzetPlugin.AttachWidzet();
HmEGAppManager.GetAppManager(0).SettingManager.PropertyChanged += PropertyChanged;
//#if !DEBUG //모델러 모드 강제 (배포 전 작업, 주석 해제 후 사용)
// HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode = ModelerMode.ModelingNViewing;
//#endif
_snap = new SnapAssist(this);
_snap.Attach();
}
private void PropertyChanged(object? sender, System.Reflection.PropertyInfo e)
{
if(e.Name.Equals("IsSidePanelVisible"))
{
var MainViewportGrid = this.FindChild<Grid>("MainViewportGrid");
var isShow = HmEGAppManager.GetAppManager(0).SettingManager.IsSidePanelVisible;
if(isShow)
{
MainViewportGrid.ColumnDefinitions[1].Width = new GridLength(2);
MainViewportGrid.ColumnDefinitions[2].Width = new GridLength(MainViewportGrid.ColumnDefinitions[2].Width.Value, GridUnitType.Auto);
MainViewportGrid.ColumnDefinitions[2].MinWidth = 370;
}
else
{
MainViewportGrid.ColumnDefinitions[1].Width = new GridLength(0);
MainViewportGrid.ColumnDefinitions[2].Width = new GridLength(0);
MainViewportGrid.ColumnDefinitions[2].MinWidth = 0;
}
}
//#if !DEBUG //모델러 모드 강제 (배포 전 작업, 주석 해제 후 사용)
// if (e.Name.Equals("ModelerMode"))
// {
// var mode = HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode;
// if (mode != ModelerMode.ModelingNViewing)
// {
// HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode = ModelerMode.ModelingNViewing;
// HmEGAppManager.GetAppManager(0).UpdateSettingJson();
// }
// }
//#endif
}
private void titleBar_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
if(sender != null)
{
this.TitleBarHeight = (int)((System.Windows.Controls.UserControl)sender).ActualHeight;
}
}
GridLength tmpHeight;
GradientTransparencySettingPanel gradientTransparencySettingPanel = new GradientTransparencySettingPanel();
private AdornerLayer _adornerLayer;
private CustomAdorner _adorner;
private void MainWindow_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
_adornerLayer = AdornerLayer.GetAdornerLayer(settingOptionPanel);
if (_adorner != null)
{
_adornerLayer.Remove(_adorner);
_adorner = null;
return;
}
ScrollContentPresenter scrollPresenter = new ScrollContentPresenter
{
Content = gradientTransparencySettingPanel
};
_adorner = new CustomAdorner(settingOptionPanel, scrollPresenter);
_adornerLayer.Add(_adorner);
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
if (MainWindowContentGrid != null)
{
MainWindowContentGrid.RowDefinitions[2].Height = new GridLength(HmEGAppManager.GetAppManager(0).SettingManager.MainCommandPanelHeight);
tmpHeight = MainWindowContentGrid.RowDefinitions[2].Height;
}
var screens = System.Windows.Forms.Screen.AllScreens;
//뷰포트가 전부 로드되면 로딩창 닫는다.
var viewports = this.FindChildren<EGViewport>();
foreach(var viewport in viewports)
{
viewCount++;
viewport.OnRendered += Viewport_OnRendered;
}
if (HmEGAppManager.GetAppManager(0).SettingManager.IsSecondWindowOn)
{
var secondWindow = new SecondWindow();
var primaryScreen = screens.FirstOrDefault(s => s.Primary);
if (primaryScreen != null)
{
var secondaryScreen = screens
.Where(s => s.Bounds.X > primaryScreen.Bounds.X)
.OrderBy(s => s.Bounds.X)
.FirstOrDefault();
Dispatcher.BeginInvoke(new Action(() =>
{
var dpi = GetDpiForScreen();
if (secondaryScreen != null)
{
secondWindow.Left = (secondaryScreen.WorkingArea.Left) / dpi;
secondWindow.Top = (secondaryScreen.WorkingArea.Top) / dpi;
}
else
{
secondWindow.Left = (primaryScreen.WorkingArea.Left) / dpi;
secondWindow.Top = (primaryScreen.WorkingArea.Top) / dpi;
}
secondWindow.Show();
}));
}
else
{
secondWindow.Show();
}
var viewports2 = secondWindow.FindChildren<EGViewport>();
if (HmEGAppManager.GetAppManager(0).SettingManager.IsSecondWindowOn)
{
//세컨드윈도우 활성화를위한
foreach (var viewport in viewports2)
{
viewCount2++;
viewport.OnRendered += Viewport_OnRendered2;
}
}
else
{
foreach (var viewport in viewports2)
{
HmEGAppManager.GetAppManager(0).UnRegisterViewport(viewport);
}
}
}
totalViewCount = viewCount + viewCount2;
MainWindowLoadingHelper.Instance.IsLoaded = true;
Application.Current.MainWindow = this;
IsSetLayout = true;
CommandPanelViewModel.IncludedControls.Add(this);
}
private int totalViewCount = 0;
//로드안된 뷰포트 수
private int viewCount = 0;
private async void Viewport_OnRendered(object? sender, EventArgs e)
{
var host = (DX11ImageSourceRenderHost)sender;
((EGViewport)host.Viewport).OnRendered -= Viewport_OnRendered;
viewCount--;
totalViewCount--;
if (totalViewCount == 0)
{
if (App.LoadingWindow != null)
{
if (App.LoadingWindow.Dispatcher.CheckAccess())
App.LoadingWindow.Close();
else
App.LoadingWindow.Dispatcher.Invoke(() => App.LoadingWindow.Close());
}
ResourceManager.Instance.GetMaterialImages();
}
if (viewCount == 0)
{
try
{
await PluginLoader.LoadProjectPluginsAsync();
}
catch (Exception ex) { }
//모든 뷰포트가 로드되면 로딩창 닫기전에 메인창 활성화 - 안하면 뒤로간다
this.Activate();
HmEGAppManager.GetAppManager(0).StartupCommandManager.ExecuteStartCommands();
HmEG_DebugWidzetPlugin.AttachDebugTabs();
Conference_DebugWidzetPlugin.AttachDebugTabs();
}
}
//로드안된 두번째 윈도우 뷰포트 수
private int viewCount2 = 0;
private void Viewport_OnRendered2(object? sender, EventArgs e)
{
var host = (DX11ImageSourceRenderHost)sender;
((EGViewport)host.Viewport).OnRendered -= Viewport_OnRendered2;
viewCount2--;
totalViewCount--;
//뷰포트가 로드되면 활성화
if (viewCount2 == 0)
{
Window.GetWindow((EGViewport)host.Viewport).Activate();
}
if (totalViewCount == 0)
{
App.LoadingWindow.Dispatcher.Invoke(() =>
{
App.LoadingWindow.Close();
//App.LoadingWindow.Dispatcher.InvokeShutdown();
});
ResourceManager.Instance.GetMaterialImages();
}
}
private double GetDpiForScreen()
{
var source = PresentationSource.FromVisual(this);
var matrix = source.CompositionTarget.TransformToDevice;
double dpi = matrix.M11 * 96.0;
return dpi / 96.0;
}
private void ViewPortPanel_DragEnter(object sender, System.Windows.DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
}
else
{
e.Effects = DragDropEffects.None;
}
}
List<string> targetFiles;
readonly Dictionary<string, string> extensions = new Dictionary<string, string>()
{
{ "HANMAC Engineering Format", ".hmeg" },
{ "AutoCAD Drawing", ".dwg" },
{ "AutoCAD Drawing Exchange", ".dxf" },
{ "GRIMI Drawing File", ".grm" },
{ "Wavefront Object Format", ".obj" },
//{ "Rhino 3D Model", ".3dm" },
{ "WPB File", ".wpb" },
{ "WPB_IFC File", ".wpb_ifc" },
{ "Industry Foundation Classes", ".ifc" },
{ "Triangulated Irregular Network", ".tins" },
{ "3D Studio", ".3ds" },
{ "Collada File", ".dae" },
{ "MotionBuilder", ".fbx" },
{ "glTF Binary File", ".glb" },
{ "glTF Text File", ".gltf" },
{ "XML File", ".xml" },
};
private void ViewPortPanel_Drop(object sender, System.Windows.DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
string[] files = (string[])e.Data.GetData(DataFormats.FileDrop);
var allowedExtensions = extensions.Values.Select(ext => ext.ToLower()).ToList();
var validFiles = files.Where(file =>
{
string extension = System.IO.Path.GetExtension(file).ToLower();
return allowedExtensions.Contains(extension);
}).ToArray();
if (validFiles.Length > 1)
{
if (!LoadingWindowHelperThread.Instance.IsOpen)
LoadingWindowHelperThread.Instance.Open();
var sb = new StringBuilder();
sb.Append("! ImportByPath ");
for (int i = 0; i < files.Count(); i++)
{
sb.Append("\"" + files[i] + "\"");
if (i != files.Count() - 1)
sb.Append("\u001F,");
}
ECommandManager.ExcuteCommand(sb.ToString());
}
else if(validFiles.Length == 1)
{
string file = validFiles[0];
var owner = Application.Current.Windows.OfType<MainWindow>().FirstOrDefault();
MessageBoxResult result;
if (owner != null)
{
result = MessageBox.Show(
owner,
"파일을 Import 하시겠습니까?\n'아니오'를 선택하면 Open됩니다.",
"파일 처리 옵션",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question
);
}
else
{
result = MessageBox.Show(
"파일을 Import 하시겠습니까?\n'아니오'를 선택하면 Open됩니다.",
"파일 처리 옵션",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question
);
}
if (result == MessageBoxResult.Yes)
{
if (!LoadingWindowHelperThread.Instance.IsOpen)
LoadingWindowHelperThread.Instance.Open();
var sb = new StringBuilder();
sb.Append("! ImportByPath ");
foreach (var vfile in validFiles)
{
sb.Append("\"" + vfile + "\"");
}
ECommandManager.ExcuteCommand(sb.ToString());
}
else if (result == MessageBoxResult.No)
{
HmEGAppManager.GetAppManager(0).FileManager.Open(file);
}
}
}
}
bool IsSetLayout;
private void CommandPanel_SizeChanged(object sender, SizeChangedEventArgs e)
{
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
if (MainWindowContentGrid != null & IsSetLayout)
{
HmEGAppManager.GetAppManager(0).SettingManager.MainCommandPanelHeight = MainWindowContentGrid.RowDefinitions[2].Height.Value;
tmpHeight = MainWindowContentGrid.RowDefinitions[2].Height.Value < 100 ? tmpHeight : MainWindowContentGrid.RowDefinitions[2].Height;
HmEGAppManager.GetAppManager(0).UpdateSettingJson();
return;
}
}
private void GridSplitter_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
if (MainWindowContentGrid != null)
{
if(MainWindowContentGrid.RowDefinitions[2].Height.Value == 0)
{
MainWindowContentGrid.RowDefinitions[2].Height = tmpHeight;
}
else
{
MainWindowContentGrid.RowDefinitions[2].Height = new GridLength(0);
}
}
}
}
public class BoolToParameterConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
if (boolValue)
{
if (parameter != null)
{
return ConvertParameter(parameter, targetType);
}
return 0;
}
else
{
return 0;
}
}
return 0;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException("ConvertBack is not implemented.");
}
private object ConvertParameter(object parameter, Type targetType)
{
if (parameter is string paramString)
{
if (targetType == typeof(double))
return double.TryParse(paramString, out double result) ? result : 0;
if (targetType == typeof(int))
return int.TryParse(paramString, out int result) ? result : 0;
if (targetType == typeof(GridLength))
return double.TryParse(paramString, out double result) ? new GridLength(result) : new GridLength(0);
}
return parameter;
}
}
}