Skip to content
Development
Hook

Hooks

What claude-forge runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
claude-forge
80026 skills12 agents38 commands8 hooks
+1
Install
> /plugin marketplace add sangrokjung/claude-forge
> /plugin install claude-forge@claude-forge

Ships with claude-forge. Installing the plugin gets these hooks.

What fires, and when

_comment

_v3_events

PreToolUse

  • MatchesBash~/.claude/hooks/remote-command-guard.sh
  • Matchesmcp__*~/.claude/hooks/rate-limiter.sh~/.claude/hooks/mcp-usage-tracker.sh

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • ~/.claude/hooks/context-sync-suggest.sh
  • ~/.claude/hooks/forge-update-check.sh

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • ~/.claude/hooks/work-tracker-prompt.sh

PostToolUse

  • ~/.claude/hooks/work-tracker-tool.sh~/.claude/hooks/output-secret-filter.sh~/.claude/skills/continuous-learning-v2/hooks/observe.sh
  • MatchesEdit~/.claude/hooks/code-quality-reminder.sh
  • MatchesWrite~/.claude/hooks/code-quality-reminder.sh
  • MatchesEdit|Write~/.claude/hooks/security-auto-trigger.sh

Stop

  • ~/.claude/hooks/work-tracker-stop.sh
  • ~/.claude/hooks/session-wrap-suggest.sh

TaskCompleted

  • ~/.claude/hooks/task-completed.sh
Read hooks/hooks.json

Where it lives

  • hooks/code-quality-reminder.shRunsGitHub
    Read the script
    #!/bin/bash
    # code-quality-reminder.sh - PostToolUse Hook (Edit/Write)
    # 코드 수정 후 품질 체크 리마인더를 stderr로 출력
    # Claude에게 셀프 체크를 유도하는 간결한 메시지
    # exit 0 필수 (세션 방해 금지)
    
    INPUT=$(cat)
    
    TOOL_NAME=$(echo "$INPUT" | python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        print(d.get('tool_name', ''))
    except:
        pass
    " 2>/dev/null)
    
    if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
        exit 0
    fi
    
    FILE_PATH=$(echo "$INPUT" | python3 -c "
    import sys, json
    try:
        d = json.load(sys.stdin)
        inp = d.get('tool_input', {})
        print(inp.get('file_path', ''))
    except:
        pass
    " 2>/dev/null)
    
    # 코드 파일만 대상 (md, txt, json, yaml 등 제외)
    case "$FILE_PATH" in
        *.ts|*.tsx|*.js|*.jsx|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.sh)
            ;;
        *)
            exit 0
            ;;
    esac
    
    echo "[code-quality] 수정된 파일의 에러 핸들링, 불변성 패턴, 입력 검증을 확인하세요." >&2
    
    exit 0
    
  • hooks/context-sync-suggest.shRunsGitHub
    Read the script
    #!/bin/bash
    HOOK_GUARD_SKIP_STDIN=1
    source "$HOME/.claude/libs/hook-guard.sh"
    # context-sync-suggest.sh - SessionStart Hook
    # 마지막 세션 종료 후 일정 시간이 경과했으면 /context-sync 안내
    # OMC session-start.mjs, project-memory-session.mjs와 독립 공존
    # exit 0 필수
    
    INPUT=$(cat)
    
    MSG=$(echo "$INPUT" | $PYTHON3 -c "
    import sys, json, os, time
    from datetime import datetime, timezone, timedelta
    
    try:
        d = json.load(sys.stdin)
    except:
        sys.exit(0)
    
    sid = d.get('session_id', '')
    if not sid:
        sys.exit(0)
    
    # buffer.jsonl에서 마지막 session_end 찾기
    work_log = os.path.expanduser('~/.claude/work-log/buffer.jsonl')
    if not os.path.exists(work_log):
        sys.exit(0)
    
    last_end = None
    try:
        with open(work_log, 'r') as f:
            for line in f:
                try:
                    ev = json.loads(line.strip())
                    if ev.get('event') == 'session_end':
                        last_end = ev.get('ts', '')
                except:
                    continue
    except:
        sys.exit(0)
    
    if not last_end:
        sys.exit(0)
    
    # 4시간 이상 경과 시 제안
    try:
        last_dt = datetime.fromisoformat(last_end)
        now = datetime.now(timezone(timedelta(hours=9)))
        gap_hours = (now - last_dt).total_seconds() / 3600
        if gap_hours < 4:
            sys.exit(0)
    except:
        sys.exit(0)
    
    gap_display = f'{int(gap_hours)}시간' if gap_hours < 48 else f'{int(gap_hours/24)}일'
    print(f'[Context Sync] 마지막 세션 이후 {gap_display} 경과. /context-sync로 놓친 활동을 확인하세요.')
    " 2>/dev/null)
    
    # 첫 사용자 감지: .forge-onboarded 마커 없으면 /guide 안내
    if [[ ! -f "$HOME/.claude/.forge-onboarded" ]]; then
        if [[ -n "$MSG" ]]; then
            MSG="$MSG
    [Claude Forge] 처음이신가요? /guide 로 시작해보세요."
        else
            MSG="[Claude Forge] 처음이신가요? /guide 로 시작해보세요."
        fi
    fi
    
    if [[ -n "$MSG" ]]; then
        $PYTHON3 -c "
    import json, sys
    msg = sys.argv[1]
    output = {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': msg}}
    print(json.dumps(output, ensure_ascii=False))
    " "$MSG" 2>/dev/null
    fi
    
    exit 0
    
  • hooks/db-guard.shGitHub
  • hooks/expensive-mcp-warning.shGitHub
  • hooks/forge-update-check.shRunsGitHub
    Read the script
    #!/bin/bash
    # forge-update-check.sh - SessionStart Hook
    # 원격에 claude-forge 새 버전이 있는지 체크하고 /forge-update 제안
    # 4시간 쿨다운, 4초 네트워크 타임아웃
    # exit 0 필수
    
    INPUT=$(cat)
    
    MSG=$(echo "$INPUT" | python3 -c "
    import sys, json, os, subprocess, time
    
    try:
        d = json.load(sys.stdin)
    except Exception:
        sys.exit(0)
    
    sid = d.get('session_id', '')
    if not sid:
        sys.exit(0)
    
    # 메타파일 로드
    meta_path = os.path.expanduser('~/.claude/.forge-meta.json')
    if not os.path.exists(meta_path):
        sys.exit(0)
    
    try:
        with open(meta_path) as f:
            meta = json.load(f)
    except Exception:
        sys.exit(0)
    
    repo_path = meta.get('repo_path', '')
    if not repo_path:
        sys.exit(0)
    
    # 경로 정규화 (심링크 해제) + 저장소 검증
    repo_path = os.path.realpath(repo_path)
    if not os.path.isdir(os.path.join(repo_path, '.git')):
        sys.exit(0)
    
    # claude-forge 저장소인지 특징 파일로 검증
    plugin_json = os.path.join(repo_path, '.claude-plugin', 'plugin.json')
    if not os.path.isfile(plugin_json):
        sys.exit(0)
    
    # 4시간 쿨다운: ~/.claude/ 하위에 마커 파일 (보안상 /tmp 사용 안 함)
    check_marker = os.path.expanduser('~/.claude/.forge-update-last-check')
    if os.path.exists(check_marker):
        try:
            with open(check_marker) as f:
                last_check = float(f.read().strip())
            if time.time() - last_check < 14400:  # 4시간
                sys.exit(0)
        except (ValueError, IOError):
            pass  # 파싱 실패 시 체크 실행
    
    # git fetch (4초 타임아웃, settings.json의 훅 timeout 5초와 맞춤)
    try:
        result = subprocess.run(
            ['git', '-C', repo_path, 'fetch', 'origin', '--quiet'],
            capture_output=True, text=True, timeout=4
        )
        if result.returncode != 0:
            sys.exit(0)
    except subprocess.TimeoutExpired:
        sys.exit(0)
    except Exception:
        sys.exit(0)
    
    # 기본 브랜치 동적 감지
    try:
        default_branch_ref = subprocess.run(
            ['git', '-C', repo_path, 'symbolic-ref', 'refs/remotes/origin/HEAD'],
            capture_output=True, text=True, timeout=2
        ).stdout.strip()
        default_branch = default_branch_ref.replace('refs/remotes/origin/', '') if default_branch_ref else 'main'
    except Exception:
        default_branch = 'main'
    
    # HEAD vs origin/{default_branch} 비교
    try:
        local_head = subprocess.run(
            ['git', '-C', repo_path, 'rev-parse', 'HEAD'],
            capture_output=True, text=True, timeout=2
        ).stdout.strip()
    
        remote_head = subprocess.run(
            ['git', '-C', repo_path, 'rev-parse', f'origin/{default_branch}'],
            capture_output=True, text=True, timeout=2
        ).stdout.strip()
    
        # 마커 업데이트: 비교 로직이 성공적으로 완료된 후에만 기록
        try:
            with open(check_marker, 'w') as f:
                f.write(str(time.time()))
        except Exception:
            pass
    
        if local_head == remote_head:
            sys.exit(0)
    
        current_ver = meta.get('version', '?')
        print(f'[Claude Forge] 새 업데이트가 있습니다 (현재 v{current_ver}). /forge-update 로 업데이트하세요.')
    
    except Exception:
        sys.exit(0)
    " 2>/dev/null)
    
    if [[ -n "$MSG" ]]; then
        echo "$MSG" >&2
    fi
    
    exit 0
    
  • hooks/mcp-usage-tracker.shRunsGitHub
    Read the script
    #!/bin/bash
    # MCP Usage Tracker - PreToolUse Hook
    # Logs MCP tool calls to ~/.claude/mcp-usage.log
    #
    # Hook trigger: PreToolUse (all mcp__* tool calls)
    # Exit codes: 0 = allow (never blocks)
    
    # Read tool call JSON from stdin
    INPUT=$(cat)
    
    echo "$INPUT" | python3 -c "
    import sys, json, os
    from datetime import datetime, timezone, timedelta
    
    try:
        d = json.load(sys.stdin)
    except:
        sys.exit(0)
    
    tool = d.get('tool_name', '')
    if not tool.startswith('mcp__'):
        sys.exit(0)
    
    parts = tool.split('__')
    server = parts[1] if len(parts) >= 2 else ''
    session = d.get('session_id', '') or f'{os.getppid()}'
    
    kst = timezone(timedelta(hours=9))
    ts = datetime.now(kst).isoformat()
    
    log = os.path.expanduser('~/.claude/mcp-usage.log')
    with open(log, 'a') as f:
        f.write(f'{ts} | {server} | {tool} | {session}\n')
    " 2>/dev/null
    
    exit 0
    
  • hooks/output-secret-filter.shRunsGitHub
    Read the script
    #!/bin/bash
    # Output Secret Filter - PostToolUse Hook
    # 도구 실행 결과에서 시크릿을 감지하여 마스킹
    #
    # Hook trigger: PostToolUse (모든 도구)
    # Exit codes: 0 = 항상 허용 (출력만 수정)
    #
    # 동작 방식:
    #   - stdin으로 도구 실행 결과 JSON 수신
    #   - tool_result에서 시크릿 패턴 감지
    #   - 감지 시 마스킹된 결과를 stdout으로 출력 (Claude가 마스킹된 값만 봄)
    #   - 마스킹 발생 시 security.log에 기록 (값 자체는 기록하지 않음)
    
    # 원격 세션이 아니면 검사 건너뜀
    if [[ -z "${OPENCLAW_SESSION_ID:-}" ]]; then
        exit 0
    fi
    
    # stdin에서 JSON 읽기
    INPUT=$(cat)
    
    # 환경변수로 전달하여 Python에서 처리
    export _FILTER_INPUT="$INPUT"
    export _SECURITY_LOG="$HOME/.claude/security.log"
    
    python3 << 'FILTER_SCRIPT'
    import os
    import sys
    import json
    import re
    from datetime import datetime
    
    input_json = os.environ.get("_FILTER_INPUT", "")
    security_log = os.environ.get("_SECURITY_LOG", "")
    
    if not input_json:
        sys.exit(0)
    
    try:
        data = json.loads(input_json)
    except (json.JSONDecodeError, ValueError):
        sys.exit(0)
    
    # tool_result에서 출력 텍스트 추출
    tool_result = data.get("tool_result", "")
    if isinstance(tool_result, dict):
        # 딕셔너리인 경우 문자열로 변환
        tool_result = json.dumps(tool_result, ensure_ascii=False)
    elif not isinstance(tool_result, str):
        tool_result = str(tool_result)
    
    if not tool_result:
        sys.exit(0)
    
    # 마스킹 패턴 정의 (패턴, 설명)
    SECRET_PATTERNS = [
        # API 키 패턴
        (r'\bsk-[a-zA-Z0-9_-]{20,}\b', "OpenAI API Key"),
        (r'\bsk-proj-[a-zA-Z0-9_-]{20,}\b', "OpenAI Project Key"),
        (r'\bAKIA[A-Z0-9]{16,}\b', "AWS Access Key"),
        (r'\bxoxb-[a-zA-Z0-9-]{20,}\b', "Slack Bot Token"),
        (r'\bxoxp-[a-zA-Z0-9-]{20,}\b', "Slack User Token"),
        (r'\bghp_[a-zA-Z0-9]{36,}\b', "GitHub PAT"),
        (r'\bghs_[a-zA-Z0-9]{36,}\b', "GitHub App Token"),
        (r'\bgho_[a-zA-Z0-9]{36,}\b', "GitHub OAuth Token"),
        (r'\bghu_[a-zA-Z0-9]{36,}\b', "GitHub User Token"),
        (r'\bglpat-[a-zA-Z0-9_-]{20,}\b', "GitLab PAT"),
        (r'\bnpm_[a-zA-Z0-9]{36,}\b', "NPM Token"),
    
        # Bearer/Auth 토큰
        (r'(?i)\bBearer\s+[a-zA-Z0-9_.-]{20,}\b', "Bearer Token"),
        (r'(?i)\btoken=[a-zA-Z0-9_.-]{20,}\b', "Token Parameter"),
        (r'(?i)\bauth=[a-zA-Z0-9_.-]{20,}\b', "Auth Parameter"),
        (r'(?i)\bapi[_-]?key=[a-zA-Z0-9_.-]{20,}\b', "API Key Parameter"),
    
        # 비밀번호/시크릿 패턴
        (r'(?i)\bpassword=[^\s&]{8,}\b', "Password Parameter"),
        (r'(?i)\bpasswd=[^\s&]{8,}\b', "Password Parameter"),
        (r'(?i)\bsecret=[^\s&]{20,}\b', "Secret Parameter"),
    
        # 환경변수 값 (KEY=VALUE 형식에서 값 부분)
        (r'(?i)\bAWS_SECRET_ACCESS_KEY=[^\s]{20,}\b', "AWS Secret Key"),
        (r'(?i)\bOPENAI_API_KEY=[^\s]{20,}\b', "OpenAI Key Value"),
        (r'(?i)\bANTHROPIC_API_KEY=[^\s]{20,}\b', "Anthropic Key Value"),
        (r'(?i)\bTELEGRAM_BOT_TOKEN=[^\s]{20,}\b', "Telegram Token Value"),
        (r'(?i)\bGITHUB_TOKEN=[^\s]{20,}\b', "GitHub Token Value"),
        (r'(?i)\bSUPABASE_SERVICE_ROLE_KEY=[^\s]{20,}\b', "Supabase Key Value"),
        (r'(?i)\bDATABASE_URL=[^\s]{20,}\b', "Database URL Value"),
    
        # Private Key 블록
        (r'-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----', "Private Key"),
    
        # 긴 Base64 같은 문자열 (40자 이상 연속 영숫자, 컨텍스트 의존)
        (r'(?i)(?:key|secret|token|password|credential|auth)[\s=:]+["\']?[a-zA-Z0-9+/]{40,}={0,2}["\']?', "Potential Base64 Secret"),
    ]
    
    import base64
    import urllib.parse
    
    def decode_layers(text):
        """base64, URL 인코딩을 디코딩하여 숨겨진 시크릿을 탐지"""
        decoded_variants = []
        # base64 디코딩 시도
        # base64로 보이는 청크를 찾아서 디코딩
        b64_pattern = re.compile(r'[A-Za-z0-9+/]{20,}={0,2}')
        for m in b64_pattern.finditer(text):
            try:
                decoded = base64.b64decode(m.group(0), validate=True).decode("utf-8", errors="ignore")
                if decoded and len(decoded) >= 10:
                    decoded_variants.append(decoded)
            except Exception:
                pass
        # URL 디코딩 시도
        try:
            url_decoded = urllib.parse.unquote(text)
            if url_decoded != text:
                decoded_variants.append(url_decoded)
        except Exception:
            pass
        return decoded_variants
    
    def mask_match(original):
        """매칭된 문자열을 마스킹"""
        if len(original) > 16:
            return original[:8] + "***MASKED***" + original[-4:]
        return original[:4] + "***MASKED***"
    
    masked_output = tool_result
    masked_count = 0
    masked_types = []
    
    # 1단계: 원본 텍스트에서 직접 매칭
    for pattern, desc in SECRET_PATTERNS:
        matches = list(re.finditer(pattern, masked_output))
        if matches:
            for match in reversed(matches):
                original = match.group(0)
                masked_output = masked_output[:match.start()] + mask_match(original) + masked_output[match.end():]
                masked_count += 1
            if desc not in masked_types:
                masked_types.append(desc)
    
    # 2단계: 인코딩 우회 탐지 (디코딩된 텍스트에서 시크릿 발견 시 인코딩된 원본 청크를 마스킹)
    decoded_variants = decode_layers(tool_result)
    for decoded_text in decoded_variants:
        for pattern, desc in SECRET_PATTERNS:
            if re.search(pattern, decoded_text):
                # 디코딩된 텍스트에서 시크릿 발견 → 원본의 base64/URL 인코딩 청크를 마스킹
                b64_pattern = re.compile(r'[A-Za-z0-9+/]{20,}={0,2}')
                for m in b64_pattern.finditer(masked_output):
                    try:
                        d = base64.b64decode(m.group(0), validate=True).decode("utf-8", errors="ignore")
                        if re.search(pattern, d):
                            chunk = m.group(0)
                            masked_output = masked_output[:m.start()] + mask_match(chunk) + masked_output[m.end():]
                            masked_count += 1
                            if desc not in masked_types:
                                masked_types.append(desc)
                            break
                    except Exception:
                        pass
                # URL 인코딩 우회 탐지
                try:
                    url_decoded = urllib.parse.unquote(masked_output)
                    if url_decoded != masked_output and re.search(pattern, url_decoded):
                        # URL 인코딩된 시크릿 → %XX 시퀀스를 포함한 구간 마스킹
                        pct_pattern = re.compile(r'(?:%[0-9A-Fa-f]{2}[A-Za-z0-9_.~-]*){5,}')
                        for pm in reversed(list(pct_pattern.finditer(masked_output))):
                            decoded_chunk = urllib.parse.unquote(pm.group(0))
      
  • hooks/rate-limiter.shRunsGitHub
    Read the script
    #!/bin/bash
    # Rate Limiter - PreToolUse Hook (경량화 버전)
    # Python → bash 산술 전환, atomic write
    #
    # Hook trigger: PreToolUse (mcp__*)
    # Exit codes: 0 = 허용, 2 = 차단 (속도 제한 초과)
    #
    # 제한 (로컬): 분당 60, 시간당 1000, 일 8000
    # 제한 (원격): 분당 30, 시간당 500, 일 5000
    
    # 세션 ID 결정
    if [[ -n "${OPENCLAW_SESSION_ID:-}" ]]; then
      SESSION_ID="${OPENCLAW_SESSION_ID}"
      LIMIT_MIN=30; LIMIT_HOUR=500; LIMIT_DAY=5000
    else
      SESSION_ID="local-${PPID}"
      LIMIT_MIN=60; LIMIT_HOUR=1000; LIMIT_DAY=8000
    fi
    
    # 타임스탬프 파일 (/tmp로 전환 — 디스크 I/O 감소)
    RATE_FILE="/tmp/claude-rate-limits-${SESSION_ID}.log"
    SECURITY_LOG="$HOME/.claude/security.log"
    
    NOW=$(date +%s)
    MIN_AGO=$((NOW - 60))
    HOUR_AGO=$((NOW - 3600))
    DAY_AGO=$((NOW - 86400))
    
    # 오래된 항목 정리 + 카운팅 (1회 순회)
    COUNT_MIN=0
    COUNT_HOUR=0
    COUNT_DAY=0
    CLEANED=""
    
    if [[ -f "$RATE_FILE" ]]; then
      while IFS= read -r ts; do
        # 숫자가 아니면 건너뜀
        [[ "$ts" =~ ^[0-9]+$ ]] || continue
        if (( ts > DAY_AGO )); then
          CLEANED="${CLEANED}${ts}\n"
          (( COUNT_DAY++ ))
          if (( ts > HOUR_AGO )); then
            (( COUNT_HOUR++ ))
            if (( ts > MIN_AGO )); then
              (( COUNT_MIN++ ))
            fi
          fi
        fi
      done < "$RATE_FILE"
    fi
    
    # 제한 검사
    BLOCKED=""
    if (( COUNT_MIN >= LIMIT_MIN )); then
      BLOCKED="분당 속도 제한 초과 (${COUNT_MIN}/${LIMIT_MIN})"
      LIMIT_TYPE="per_minute"
    elif (( COUNT_HOUR >= LIMIT_HOUR )); then
      BLOCKED="시간당 속도 제한 초과 (${COUNT_HOUR}/${LIMIT_HOUR})"
      LIMIT_TYPE="per_hour"
    elif (( COUNT_DAY >= LIMIT_DAY )); then
      BLOCKED="일일 속도 제한 초과 (${COUNT_DAY}/${LIMIT_DAY})"
      LIMIT_TYPE="per_day"
    fi
    
    if [[ -n "$BLOCKED" ]]; then
      echo "BLOCKED: $BLOCKED" >&2
      # 정리만 저장 (차단된 요청은 카운트 안 함)
      printf '%b' "$CLEANED" > "${RATE_FILE}.tmp" && mv "${RATE_FILE}.tmp" "$RATE_FILE"
      # 보안 로그
      if [[ -n "$SECURITY_LOG" ]]; then
        echo "$(date '+%Y-%m-%d %H:%M:%S') | RATE_LIMITED | type=${LIMIT_TYPE} | session=${SESSION_ID}" >> "$SECURITY_LOG" 2>/dev/null
      fi
      exit 2
    fi
    
    # 현재 타임스탬프 추가 + 정리된 데이터 저장 (atomic write)
    printf '%b' "${CLEANED}${NOW}\n" > "${RATE_FILE}.tmp" && mv -f "${RATE_FILE}.tmp" "$RATE_FILE"
    
    exit 0
    
  • hooks/remote-command-guard.shRunsGitHub
  • hooks/security-auto-trigger.shRunsGitHub
  • hooks/session-wrap-suggest.shRunsGitHub
  • hooks/task-completed.shRunsGitHub
  • hooks/work-tracker-prompt.shRunsGitHub
  • hooks/work-tracker-stop.shRunsGitHub
  • hooks/work-tracker-tool.shRunsGitHub

All 15 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withclaude-forge

Supercharge Claude Code with 11 AI agents, 36 commands & 15 skills — the claude-code plugin framework inspired by oh-my-zsh. 6-layer security hooks included. 5-min install.

Get the whole plugin