Skip to content
Agent Orchestration
Hook

Hooks

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

From plugin
model-matchmaker
1683 skills14 hooks
Install
$ npx -y skills add coyvalyss1/model-matchmaker --agent claude-code

Ships with model-matchmaker. Installing the plugin gets these hooks.

Where it lives

  • hooks/analytics.shGitHub
    Read the script
    #!/bin/bash
    # Model Matchmaker Analytics
    # Parses NDJSON logs and prints accuracy/usage metrics.
    # Usage: ./hooks/analytics.sh [--json] [--days N]
    
    LOG_PATH="${HOME}/.cursor/hooks/model-matchmaker.ndjson"
    
    if [ ! -f "$LOG_PATH" ]; then
        echo "No analytics data found at $LOG_PATH"
        echo "Start using Model Matchmaker and data will be collected automatically."
        exit 0
    fi
    
    DAYS="${2:-0}"
    JSON_OUT=false
    [ "$1" = "--json" ] && JSON_OUT=true && DAYS="${3:-0}"
    [ "$1" = "--days" ] && DAYS="$2"
    
    python3 -c "
    import json, sys
    from datetime import datetime, timedelta
    from collections import Counter, defaultdict
    
    log_path = '$LOG_PATH'
    days_filter = int('$DAYS')
    json_out = $([[ "$JSON_OUT" == "true" ]] && echo "True" || echo "False")
    
    recs = []
    completions = []
    cutoff = None
    if days_filter > 0:
        cutoff = datetime.now() - timedelta(days=days_filter)
    
    with open(log_path) as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except:
                continue
            if cutoff:
                ts = datetime.fromisoformat(entry.get('ts', ''))
                if ts < cutoff:
                    continue
            if entry.get('event') == 'recommendation':
                recs.append(entry)
            elif entry.get('event') == 'completion':
                completions.append(entry)
    
    total_recs = len(recs)
    if total_recs == 0:
        print('No recommendation data found.' if not json_out else json.dumps({'error': 'no data'}))
        sys.exit(0)
    
    actions = Counter(r['action'] for r in recs)
    rec_types = Counter(r['recommendation'] for r in recs)
    models_used = Counter(r['model'] for r in recs)
    
    overrides = [r for r in recs if r['action'] == 'OVERRIDE']
    blocks = [r for r in recs if r['action'] == 'BLOCK']
    allows = [r for r in recs if r['action'] == 'ALLOW']
    
    override_rate = len(overrides) / total_recs * 100 if total_recs else 0
    block_rate = len(blocks) / total_recs * 100 if total_recs else 0
    
    # Override analysis: what was recommended vs what model was used
    override_details = Counter()
    for o in overrides:
        override_details[f'{o[\"model\"]} (rec: {o[\"recommendation\"]})'] += 1
    
    # Block analysis: what model was being used vs recommendation
    block_details = Counter()
    for b in blocks:
        block_details[f'{b[\"model\"]} -> {b[\"recommendation\"]}'] += 1
    
    # Completion analysis: correlate recommendations with outcomes
    completion_by_conv = {}
    for c in completions:
        cid = c.get('conversation_id', '')
        if cid:
            completion_by_conv[cid] = c
    
    rec_by_conv = defaultdict(list)
    for r in recs:
        cid = r.get('conversation_id', '')
        if cid:
            rec_by_conv[cid].append(r)
    
    completed_convs = 0
    errored_convs = 0
    aborted_convs = 0
    override_completed = 0
    override_errored = 0
    for cid, comp in completion_by_conv.items():
        status = comp.get('status', '')
        had_override = any(r['action'] == 'OVERRIDE' for r in rec_by_conv.get(cid, []))
        if status == 'completed':
            completed_convs += 1
            if had_override: override_completed += 1
        elif status == 'error':
            errored_convs += 1
            if had_override: override_errored += 1
        elif status == 'aborted':
            aborted_convs += 1
    
    if json_out:
        print(json.dumps({
            'total_recommendations': total_recs,
            'actions': dict(actions),
            'recommendations': dict(rec_types),
            'models_used': dict(models_used),
            'override_rate': round(override_rate, 1),
            'block_rate': round(block_rate, 1),
            'override_details': dict(override_details),
            'block_details': dict(block_details),
            'completions': {
                'completed': completed_convs,
                'errored': errored_convs,
                'aborted': aborted_convs,
            },
            'override_outcomes': {
                'completed': override_completed,
                'errored': override_errored,
            },
        }, indent=2))
    else:
        print('=' * 50)
        print('  MODEL MATCHMAKER ANALYTICS')
        period = f' (last {days_filter} days)' if days_filter > 0 else ''
        print(f'  {total_recs} recommendations tracked{period}')
        print('=' * 50)
    
        print(f'\n  Actions:')
        for action, count in actions.most_common():
            pct = count / total_recs * 100
            print(f'    {action:>10}: {count:>4} ({pct:.1f}%)')
    
        print(f'\n  Override rate: {override_rate:.1f}%')
        print(f'  Block rate:    {block_rate:.1f}%')
    
        if override_details:
            print(f'\n  Override breakdown (you disagreed):')
            for detail, count in override_details.most_common():
                print(f'    {detail}: {count}')
    
        if block_details:
            print(f'\n  Block breakdown (model mismatch):')
            for detail, count in block_details.most_common():
                print(f'    {detail}: {count}')
    
        print(f'\n  Recommendations:')
        for rec, count in rec_types.most_common():
            pct = count / total_recs * 100
            print(f'    {rec:>10}: {count:>4} ({pct:.1f}%)')
    
        print(f'\n  Models used:')
        for model, count in models_used.most_common():
            print(f'    {model}: {count}')
    
        total_completions = completed_convs + errored_convs + aborted_convs
        if total_completions > 0:
            print(f'\n  Task Outcomes ({total_completions} conversations):')
            print(f'    Completed: {completed_convs}')
            print(f'    Errored:   {errored_convs}')
            print(f'    Aborted:   {aborted_convs}')
            if override_completed + override_errored > 0:
                print(f'\n  Override Outcomes:')
                print(f'    Completed after override: {override_completed}')
                print(f'    Errored after override:   {override_errored}')
    
        # Auto-switch impact tracking
        auto_switched = [e for e in recs if e.get('auto_switch_attempted') == True]
        manual_blocks = [e for e in blocks if not e.get('auto_switch_attempted')]
        
        if auto_switched:
            print(f'\n  Auto-Switch Impact:')
            print(f'    Blocks with auto-switch: {len(auto_switched)}')
            print(f'    Manual blocks: 
  • hooks/auto-switch-model.shGitHub
    Read the script
    #!/bin/bash
    # Auto-Switch Model via Keyboard Automation
    # Uses Cmd+/ to open model dropdown, then arrow keys to navigate to target model.
    # 
    # Cursor's official keyboard shortcuts:
    # - Cmd+/: Loop between AI models (opens dropdown)
    # - Arrow keys + Enter: Navigate and select
    #
    # Proxy approach: Cursor's subprocess doesn't have macOS Accessibility permissions,
    # so we write a temp script and execute via Terminal.app (which does have them).
    #
    # Usage: ./auto-switch-model.sh <model_name> [--test]
    # Example: ./auto-switch-model.sh "sonnet"
    # Test mode: ./auto-switch-model.sh "sonnet" --test (bypasses enable check)
    
    MODEL="$1"
    TEST_MODE="${2:-}"
    LOG_FILE="$HOME/.cursor/hooks/auto-switch-audit.log"
    CIRCUIT_FILE="$HOME/.cursor/hooks/.auto-switch-circuit"
    LOCK_FILE="$HOME/.cursor/hooks/.auto-switch.lock"
    KILL_SWITCH="$HOME/.cursor/hooks/.auto-switch-kill"
    AUTO_SWITCH_FLAG="$HOME/.cursor/hooks/.auto-switch-enabled"
    
    # SECURITY: Emergency kill switch
    if [ -f "$KILL_SWITCH" ]; then
        echo "[$(date -Iseconds)] SECURITY: Kill switch activated" >> "$LOG_FILE"
        exit 0
    fi
    
    # SECURITY: Check if auto-switch is enabled (skip in test mode)
    if [ "$TEST_MODE" != "--test" ] && [ ! -f "$AUTO_SWITCH_FLAG" ]; then
        exit 0
    fi
    
    # SECURITY: Input validation - only allow whitelisted model names
    case "$MODEL" in
        haiku|sonnet|opus)
            ;;
        *)
            echo "[$(date -Iseconds)] SECURITY: Invalid model rejected: $MODEL" >> "$LOG_FILE"
            exit 1
            ;;
    esac
    
    # SECURITY: Execution lock - prevent simultaneous executions
    if [ -f "$LOCK_FILE" ]; then
        LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCK_FILE" 2>/dev/null || echo 0) ))
        if [ $LOCK_AGE -lt 10 ]; then
            echo "[$(date -Iseconds)] SECURITY: Lock exists, switch in progress" >> "$LOG_FILE"
            exit 0
        else
            rm -f "$LOCK_FILE"
        fi
    fi
    
    touch "$LOCK_FILE"
    trap "rm -f $LOCK_FILE" EXIT
    
    # SECURITY: Rate limiting - prevent rapid-fire switching
    CURRENT_TIME=$(date +%s)
    if [ -f "$CIRCUIT_FILE" ]; then
        LAST_SWITCH=$(cat "$CIRCUIT_FILE" 2>/dev/null || echo 0)
        TIME_DIFF=$((CURRENT_TIME - LAST_SWITCH))
        
        if [ $TIME_DIFF -lt 5 ]; then
            echo "[$(date -Iseconds)] SECURITY: Rate limit - too soon since last switch (${TIME_DIFF}s)" >> "$LOG_FILE"
            exit 0
        fi
    fi
    
    echo "$CURRENT_TIME" > "$CIRCUIT_FILE"
    
    # Detect current Cursor mode (affects dropdown positions)
    MODE_FILE="$HOME/.cursor/hooks/.cursor-mode"
    CURSOR_MODE="agent"  # default
    if [ -f "$MODE_FILE" ]; then
        CURSOR_MODE=$(cat "$MODE_FILE" 2>/dev/null || echo "agent")
    fi
    
    # Window targeting from environment (set by model-advisor.sh)
    WINDOW_TITLE="${WINDOW_TITLE:-}"
    CONVERSATION_ID="${CONVERSATION_ID:-}"
    
    # Audit log
    echo "[$(date -Iseconds)] SWITCH_ATTEMPT | Model: $MODEL | Mode: $CURSOR_MODE | Window: $WINDOW_TITLE | PID: $$ | User: $(whoami)" >> "$LOG_FILE"
    
    # Search-based model selection: type model name into dropdown search bar
    SEARCH_TERM="$MODEL"
    if [ "$CURSOR_MODE" = "plan" ] && [ "$MODEL" = "haiku" ]; then
        echo "[$(date -Iseconds)] INFO | Haiku not available in Plan mode, switching to Sonnet" >> "$LOG_FILE"
        MODEL="sonnet"
        SEARCH_TERM="sonnet"
    fi
    
    # Proxy via Terminal.app to get Accessibility permissions
    # Cursor's subprocess doesn't inherit macOS Accessibility/Automation permissions,
    # so we write a temp script and execute it through Terminal.app which has them.
    PROXY_SCRIPT="/tmp/.cursor-model-switch-$$.sh"
    RESULT_FILE="/tmp/.cursor-model-switch-$$-result.txt"
    
    cat > "$PROXY_SCRIPT" << PROXY_EOF
    #!/bin/bash
    
    # Capture the window title to target this specific Cursor window
    WINDOW_TITLE="$WINDOW_TITLE"
    
    osascript - "$MODEL" "$SEARCH_TERM" "\$WINDOW_TITLE" <<'APPLESCRIPT_EOF'
    on run argv
        set targetModel to item 1 of argv
        set searchTerm to item 2 of argv
        set targetWindow to item 3 of argv
        
        -- Activate Cursor (needed because script runs via Terminal.app)
        tell application "Cursor"
            activate
        end tell
        -- Brief wait for Cursor to come to front
        delay 0.5
        
        -- Verify Cursor is frontmost
        tell application "System Events"
            if frontmost of process "Cursor" is false then
                do shell script "echo ABORTED > $RESULT_FILE"
                return "ABORTED: Cursor not frontmost"
            end if
        end tell
        
        -- Open model dropdown with Cmd+/, then type to search
        tell application "System Events"
            tell process "Cursor"
                -- Open dropdown
                keystroke "/" using command down
                delay 1.5
                
                -- Use keystroke (not key code) to type directly into focused field
                -- keystroke respects the currently focused element
                keystroke searchTerm
                delay 1.5
                
                -- Enter to select model
                key code 36
            end tell
        end tell
        
        return "SUCCESS"
    end run
    APPLESCRIPT_EOF
    
    echo "EXIT:\$?" > "$RESULT_FILE"
    
    # Self-destruct: delete script and close Terminal window
    # AppleScript above takes ~4s total - wait until it's fully done
    sleep 0.2
    rm -f "$PROXY_SCRIPT"
    osascript -e 'tell application "Terminal" to close front window' 2>/dev/null &
    PROXY_EOF
    
    chmod +x "$PROXY_SCRIPT"
    
    # Execute through Terminal.app
    # Uses 'open' which doesn't require Apple Event permissions
    open -a Terminal "$PROXY_SCRIPT"
    
    # Wait for result (up to 8 seconds)
    for i in $(seq 1 16); do
        if [ -f "$RESULT_FILE" ]; then
            RESULT=$(cat "$RESULT_FILE")
            rm -f "$PROXY_SCRIPT" "$RESULT_FILE"
            
            if echo "$RESULT" | grep -q "EXIT:0"; then
                echo "[$(date -Iseconds)] SUCCESS | Model: $MODEL" >> "$LOG_FILE"
            else
                echo "[$(date -Iseconds)] FAILED | Model: $MODEL | Result: $RESULT" >> "$LOG_FILE"
            fi
            exit 0
        fi
        sleep 0.5
    done
    
    echo "[$(date -Iseconds)] TIMEOUT | Model: $MODEL" >> "$LOG_FILE"
    rm -f "$PROXY_SCRIPT" "$RESULT_FILE"
    
    # Close any orphaned Terminal window from the failed proxy script
    osascript -e 'tell application "Terminal
  • hooks/cc-auto-switch-ide.shGitHub
    Read the script
    #!/usr/bin/env bash
    # cc-auto-switch-ide.sh — Auto-switch model and re-send prompt in Claude Code IDE (Tier 3)
    #
    # Spawned by cc-ide-advisor.sh when auto-switch is enabled and an expensive mismatch is detected.
    # Uses AppleScript to type /model command and paste the prompt back into Claude Code webview.
    #
    # Usage: ./cc-auto-switch-ide.sh <model_name> <prompt_text>
    # Example: ./cc-auto-switch-ide.sh "haiku" "list all TODO comments"
    
    MODEL="$1"
    PROMPT="$2"
    LOG_FILE="$HOME/.claude/hooks/mm-audit.log"
    KILL_SWITCH="$HOME/.claude/hooks/.mm-kill"
    
    # SECURITY: Emergency kill switch
    if [ -f "$KILL_SWITCH" ]; then
        echo "[$(date -Iseconds)] SECURITY: Kill switch activated" >> "$LOG_FILE"
        exit 0
    fi
    
    # SECURITY: Input validation - only allow whitelisted model names
    case "$MODEL" in
        haiku|sonnet|opus)
            ;;
        *)
            echo "[$(date -Iseconds)] SECURITY: Invalid model rejected: $MODEL" >> "$LOG_FILE"
            exit 1
            ;;
    esac
    
    # Audit log
    echo "[$(date -Iseconds)] IDE_AUTO_SWITCH | Model: $MODEL | PID: $$ | User: $(whoami)" >> "$LOG_FILE"
    
    # Wait for block message to render
    sleep 0.7
    
    # Copy prompt to clipboard (backup in case AppleScript fails)
    echo "$PROMPT" | pbcopy 2>/dev/null
    
    # Execute AppleScript automation
    osascript <<EOF
    tell application "System Events"
      tell process "Cursor"
        -- Focus Claude Code input (Esc key)
        key code 53
        delay 0.4
        
        -- Type /model command
        keystroke "/model $MODEL"
        delay 0.2
        key code 36  -- Enter
        delay 1.3
        
        -- Refocus input
        key code 53
        delay 0.4
        
        -- Paste saved prompt
        keystroke "v" using command down
        delay 0.4
        
        -- Submit
        key code 36  -- Enter
      end tell
    end tell
    EOF
    
    APPLESCRIPT_EXIT=$?
    
    if [ $APPLESCRIPT_EXIT -eq 0 ]; then
        echo "[$(date -Iseconds)] IDE_AUTO_SWITCH_SUCCESS | Model: $MODEL" >> "$LOG_FILE"
    else
        echo "[$(date -Iseconds)] IDE_AUTO_SWITCH_FAILED | Model: $MODEL | Exit: $APPLESCRIPT_EXIT" >> "$LOG_FILE"
    fi
    
    exit 0
    
  • hooks/cc-effort-advisor.shGitHub
    Read the script
    #!/usr/bin/env bash
    # cc-effort-advisor.sh — Silent PreToolUse hook for Claude Code
    #
    # Fires on Agent tool calls only. Classifies the subagent prompt via
    # cc-config.json regex rules. Outputs NOTHING to Claude (zero token overhead).
    # Writes classification result to audit log for analytics only.
    #
    # Wired into ~/.claude/settings.json PreToolUse with matcher: Agent
    #
    # For all non-Agent tools: immediate silent exit (no overhead at all).
    
    KILL_SWITCH="$HOME/.claude/hooks/.mm-kill"
    CONFIG="$HOME/.claude/hooks/cc-config.json"
    AUDIT_LOG="$HOME/.claude/hooks/mm-audit.log"
    
    # --- Kill switch: silent exit ---
    [ -f "$KILL_SWITCH" ] && exit 0
    
    # --- Read hook input ---
    INPUT=$(cat)
    
    # --- Only act on Agent tool calls ---
    TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
    [ "$TOOL" != "Agent" ] && exit 0
    
    # --- Dependency check: if jq missing, silent exit ---
    command -v jq &>/dev/null || exit 0
    [ -f "$CONFIG" ] || exit 0
    
    # --- Extract subagent prompt ---
    PROMPT=$(echo "$INPUT" | python3 -c "
    import sys, json
    d = json.load(sys.stdin)
    inp = d.get('tool_input', {})
    if isinstance(inp, dict):
        print(inp.get('prompt', ''))
    else:
        print('')
    " 2>/dev/null)
    
    [ -z "$PROMPT" ] && exit 0
    
    # --- Classify prompt against config rules ---
    DEFAULT_MODEL=$(jq -r '.default.model // "sonnet"' "$CONFIG")
    DEFAULT_EFFORT=$(jq -r '.default.effort // "low"' "$CONFIG")
    MATCHED_MODEL="$DEFAULT_MODEL"
    MATCHED_EFFORT="$DEFAULT_EFFORT"
    MATCHED_RULE="default"
    
    RULE_COUNT=$(jq '.rules | length' "$CONFIG")
    for i in $(seq 0 $((RULE_COUNT - 1))); do
      PATTERN=$(jq -r ".rules[$i].pattern" "$CONFIG")
      NAME=$(jq -r ".rules[$i].name" "$CONFIG")
      if echo "$PROMPT" | grep -qiE "$PATTERN"; then
        MATCHED_MODEL=$(jq -r ".rules[$i].model" "$CONFIG")
        MATCHED_EFFORT=$(jq -r ".rules[$i].effort" "$CONFIG")
        MATCHED_RULE="$NAME"
        break
      fi
    done
    
    # --- Log to audit (no stdout — zero token overhead) ---
    TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
    PROMPT_SNIPPET="${PROMPT:0:60}"
    echo "{\"ts\":\"$TS\",\"event\":\"subagent\",\"session_id\":\"$SESSION_ID\",\"rule\":\"$MATCHED_RULE\",\"model\":\"$MATCHED_MODEL\",\"effort\":\"$MATCHED_EFFORT\",\"prompt\":\"$PROMPT_SNIPPET\"}" >> "$AUDIT_LOG"
    
    # --- Silent exit: output nothing to Claude ---
    exit 0
    
  • hooks/cc-ide-advisor.shGitHub
    Read the script
    #!/usr/bin/env bash
    # cc-ide-advisor.sh — UserPromptSubmit hook for Claude Code IDE sessions (Three-Tier)
    #
    # Fires on every user message in the IDE. Classifies the prompt via cc-config.json
    # and uses a three-tier approach for cost optimization:
    #
    # Tier 1 (Silent): Pass through with effortLevel optimization when cost delta is small
    # Tier 2 (Clipboard): Block + copy prompt to clipboard for expensive mismatches
    # Tier 3 (Auto): Full automation when auto-switch is enabled (opt-in)
    #
    # Supports +/++ prefix overrides: "+fix bug" or "++prove theorem"
    # Supports ! prefix to bypass blocking entirely
    #
    # Wired into ~/.claude/settings.json UserPromptSubmit hook.
    # Paired with cc-ide-restore.sh (Stop hook) which resets to defaults after each response.
    
    KILL_SWITCH="$HOME/.claude/hooks/.mm-kill"
    CONFIG="$HOME/.claude/hooks/cc-config.json"
    SETTINGS="$HOME/.claude/settings.json"
    AUDIT_LOG="$HOME/.claude/hooks/mm-audit.log"
    AUTO_SWITCH_FLAG="$HOME/.claude/hooks/.mm-auto-switch-enabled"
    PENDING_PROMPT_FILE="$HOME/.claude/hooks/.pending-prompt.txt"
    
    # --- Kill switch: silent exit ---
    [ -f "$KILL_SWITCH" ] && exit 0
    
    # --- Dependency check ---
    command -v jq &>/dev/null || exit 0
    [ -f "$CONFIG" ] || exit 0
    [ -f "$SETTINGS" ] || exit 0
    
    # --- Read hook input ---
    INPUT=$(cat)
    
    # --- Extract user prompt ---
    PROMPT=$(echo "$INPUT" | python3 -c "
    import sys, json
    d = json.load(sys.stdin)
    print(d.get('prompt', ''))
    " 2>/dev/null)
    
    # --- Handle ! override prefix (bypass all blocking) ---
    if [[ "$PROMPT" == "!"* ]]; then
      # Strip prefix and pass through
      PROMPT="${PROMPT:1}"
      IS_OVERRIDE=true
    else
      IS_OVERRIDE=false
    fi
    
    # --- Handle slash commands (pass through without classification) ---
    if [[ "$PROMPT" == "/"* ]]; then
      # Slash command like /model or /compact — pass through immediately
      exit 0
    fi
    
    # --- Load defaults from config ---
    DEFAULT_MODEL=$(jq -r '.default.model // "sonnet"' "$CONFIG")
    DEFAULT_EFFORT=$(jq -r '.default.effort // "low"' "$CONFIG")
    DEFAULT_THINKING=$(jq -r '.default.thinking // false' "$CONFIG")
    
    MATCHED_MODEL="$DEFAULT_MODEL"
    MATCHED_EFFORT="$DEFAULT_EFFORT"
    MATCHED_THINKING="$DEFAULT_THINKING"
    MATCHED_RULE="default"
    
    # --- Override prefix detection (++ before +) ---
    # ++ = opus + high + thinking on
    # +  = opus + medium + thinking off
    if [[ "$PROMPT" == "++"* ]]; then
      MATCHED_MODEL="opus"
      MATCHED_EFFORT="high"
      MATCHED_THINKING="true"
      MATCHED_RULE="override:++"
    elif [[ "$PROMPT" == "+"* ]]; then
      MATCHED_MODEL="opus"
      MATCHED_EFFORT="medium"
      MATCHED_THINKING="false"
      MATCHED_RULE="override:+"
    else
      # --- Classify prompt against config rules (top-to-bottom, first match wins) ---
      if [ -n "$PROMPT" ]; then
        RULE_COUNT=$(jq '.rules | length' "$CONFIG")
        for i in $(seq 0 $((RULE_COUNT - 1))); do
          PATTERN=$(jq -r ".rules[$i].pattern" "$CONFIG")
          NAME=$(jq -r ".rules[$i].name" "$CONFIG")
          if echo "$PROMPT" | grep -qiE "$PATTERN"; then
            MATCHED_MODEL=$(jq -r ".rules[$i].model" "$CONFIG")
            MATCHED_EFFORT=$(jq -r ".rules[$i].effort" "$CONFIG")
            MATCHED_THINKING=$(jq -r ".rules[$i].thinking" "$CONFIG")
            MATCHED_RULE="$NAME"
            break
          fi
        done
      fi
    fi
    
    # --- Map short alias to full model ID ---
    case "$MATCHED_MODEL" in
      haiku)  FULL_MODEL="claude-haiku-4-5-20251001" ;;
      sonnet) FULL_MODEL="claude-sonnet-4-6" ;;
      opus)   FULL_MODEL="claude-opus-4-6" ;;
      *)      FULL_MODEL="$MATCHED_MODEL" ;;
    esac
    
    # --- Read current session model from settings.json ---
    CURRENT_MODEL=$(jq -r '.model // "claude-sonnet-4-6"' "$SETTINGS")
    
    # Extract short alias from current model
    case "$CURRENT_MODEL" in
      *haiku*)  CURRENT_ALIAS="haiku" ;;
      *opus*)   CURRENT_ALIAS="opus" ;;
      *)        CURRENT_ALIAS="sonnet" ;;
    esac
    
    # --- Three-Tier Decision Logic ---
    
    # If ! override prefix, pass through regardless of mismatch
    if [ "$IS_OVERRIDE" = true ]; then
      TIER="override"
      ACTION="pass-through"
      
    # If models match, pass through silently
    elif [ "$MATCHED_MODEL" = "$CURRENT_ALIAS" ]; then
      TIER="match"
      ACTION="pass-through"
      
    # Expensive mismatches: opus → haiku or opus → sonnet (save $0.80-5.00)
    elif [ "$CURRENT_ALIAS" = "opus" ] && [ "$MATCHED_MODEL" != "opus" ]; then
      TIER="expensive-mismatch"
      ACTION="block"
      
    # Upward mismatches: haiku/sonnet → higher tier (add hint but pass through)
    elif [ "$CURRENT_ALIAS" = "haiku" ] && [ "$MATCHED_MODEL" = "sonnet" ]; then
      TIER="upward-hint"
      ACTION="pass-through-with-hint"
      
    elif [ "$CURRENT_ALIAS" = "haiku" ] && [ "$MATCHED_MODEL" = "opus" ]; then
      TIER="upward-hint"
      ACTION="pass-through-with-hint"
      
    elif [ "$CURRENT_ALIAS" = "sonnet" ] && [ "$MATCHED_MODEL" = "opus" ]; then
      TIER="upward-hint"
      ACTION="pass-through-with-hint"
      
    # Small overpay: sonnet → haiku task (overpay ~$0.03-0.10, accept for zero friction)
    elif [ "$CURRENT_ALIAS" = "sonnet" ] && [ "$MATCHED_MODEL" = "haiku" ]; then
      TIER="small-overpay"
      ACTION="pass-through"
      
    # Default: pass through
    else
      TIER="unknown"
      ACTION="pass-through"
    fi
    
    # --- Write effort + thinking to settings.json (always, even when blocking) ---
    TMP=$(mktemp)
    jq --arg model "$CURRENT_MODEL" \
       --arg effort "$MATCHED_EFFORT" \
       --argjson thinking "$MATCHED_THINKING" \
       '.model = $model | .effortLevel = $effort | .alwaysThinkingEnabled = $thinking' \
       "$SETTINGS" > "$TMP" && mv "$TMP" "$SETTINGS"
    
    # --- Audit log ---
    TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
    PROMPT_SNIPPET="${PROMPT:0:60}"
    echo "{\"ts\":\"$TS\",\"event\":\"ide-classify\",\"session_id\":\"$SESSION_ID\",\"rule\":\"$MATCHED_RULE\",\"recommended\":\"$MATCHED_MODEL\",\"current\":\"$CURRENT_ALIAS\",\"tier\":\"$TIER\",\"action\":\"$ACTION\",\"effort\":\"$MATCHED_EFFORT\",\"thinking\":$MATCHED_THINKING,\"prompt\":\"$PROMPT_SNIPPET\"}" >> "$AUDIT_LOG"
    
    # --- Execute action based on tier ---
    
    if [ "$ACTION" = "block" ]; then
      # Tier 2/3: Expensive mismatch — bloc
  • hooks/cc-ide-restore.shGitHub
    Read the script
    #!/usr/bin/env bash
    # cc-ide-restore.sh — Stop hook for Claude Code IDE sessions
    #
    # Fires when Claude finishes responding. Resets model + effortLevel +
    # alwaysThinkingEnabled back to the defaults in cc-config.json.
    #
    # This ensures that a +/++ override or a deep-work classification doesn't
    # persist into the next message (each message gets freshly classified).
    #
    # Wired into ~/.claude/settings.json Stop hook.
    
    KILL_SWITCH="$HOME/.claude/hooks/.mm-kill"
    CONFIG="$HOME/.claude/hooks/cc-config.json"
    SETTINGS="$HOME/.claude/settings.json"
    
    # --- Kill switch: silent exit ---
    [ -f "$KILL_SWITCH" ] && exit 0
    
    # --- Dependency check ---
    command -v jq &>/dev/null || exit 0
    [ -f "$CONFIG" ] || exit 0
    [ -f "$SETTINGS" ] || exit 0
    
    # --- Read defaults from config ---
    DEFAULT_MODEL=$(jq -r '.default.model // "sonnet"' "$CONFIG")
    DEFAULT_EFFORT=$(jq -r '.default.effort // "low"' "$CONFIG")
    DEFAULT_THINKING=$(jq -r '.default.thinking // false' "$CONFIG")
    
    # --- Map short alias to full model ID ---
    case "$DEFAULT_MODEL" in
      haiku)  FULL_DEFAULT="claude-haiku-4-5-20251001" ;;
      sonnet) FULL_DEFAULT="claude-sonnet-4-6" ;;
      opus)   FULL_DEFAULT="claude-opus-4-6" ;;
      *)      FULL_DEFAULT="$DEFAULT_MODEL" ;;
    esac
    
    # --- Restore settings.json to defaults ---
    TMP=$(mktemp)
    jq --arg model "$FULL_DEFAULT" \
       --arg effort "$DEFAULT_EFFORT" \
       --argjson thinking "$DEFAULT_THINKING" \
       '.model = $model | .effortLevel = $effort | .alwaysThinkingEnabled = $thinking' \
       "$SETTINGS" > "$TMP" && mv "$TMP" "$SETTINGS"
    
    exit 0
    
  • hooks/cc-launch.shGitHub
  • hooks/export-analytics-for-automation.shGitHub
  • hooks/log-cursor-mode.shGitHub
  • hooks/mm-control.shGitHub
  • hooks/model-advisor.shGitHub
  • hooks/session-init.shGitHub
  • hooks/toggle-auto-switch.shGitHub
  • hooks/track-completion.shGitHub

All 14 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 withmodel-matchmaker

Stop paying Opus prices to rename files. Stop waiting 20 seconds for responses that should take 3. A local hook for Cursor and Claude Code that classifies every prompt before it's sent and recommends the right model.

Get the whole plugin