├── .claude-code-ericbuess ├── CONTEXT_STATE.md └── precompact.log ├── .gitignore ├── CLAUDE.md ├── LICENSE ├── PROJECT_INDEX.json ├── README.md ├── agents └── index-analyzer.md ├── install.sh ├── scripts ├── find_python.sh ├── i_flag_hook.py ├── index_utils.py ├── project_index.py ├── run_python.sh └── stop_hook.py └── uninstall.sh /.claude-code-ericbuess/CONTEXT_STATE.md: -------------------------------------------------------------------------------- 1 | # 🔄 Auto-Generated Context State 2 | *Generated by PreCompact hook at 2025-08-22T11:59:41.132686* 3 | 4 | ## 📍 Current Session 5 | - **Git Branch**: `main` 6 | - **Working Directory**: `.` 7 | 8 | ## 📊 Git Status 9 | ``` 10 | M PROJECT_INDEX.json 11 | ``` 12 | 13 | ## 📝 Recently Modified Files 14 | Files changed in the last 4 hours: 15 | - `scripts/i_flag_hook.py` (54 minutes ago) 16 | - `scripts/project_index.py` (54 minutes ago) 17 | - `scripts/stop_hook.py` (54 minutes ago) 18 | - `scripts/index_utils.py` (54 minutes ago) 19 | - `tests/test_auto_create.py` (1.6 hours ago) 20 | - `tests/test_hook.py` (1.7 hours ago) 21 | - `tests/test_error_handling.py` (1.7 hours ago) 22 | - `tests/test_subagent.py` (1.7 hours ago) 23 | 24 | ## 📌 Context Notes 25 | 26 | This file was automatically generated before compact to preserve session context. 27 | It contains information about recent work that should be maintained after compact. 28 | 29 | **Key files to review after compact:** 30 | - @scripts/i_flag_hook.py 31 | - @scripts/project_index.py 32 | - @scripts/stop_hook.py 33 | - @scripts/index_utils.py 34 | - @tests/test_auto_create.py 35 | 36 | --- 37 | *This file is auto-generated. It will be updated before each compact.* -------------------------------------------------------------------------------- /.claude-code-ericbuess/precompact.log: -------------------------------------------------------------------------------- 1 | 2025-08-22T11:59:41.146794 - Generated context for manual compact 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | .venv 10 | 11 | # IDE 12 | .vscode/ 13 | .idea/ 14 | *.swp 15 | *.swo 16 | 17 | # OS 18 | .DS_Store 19 | Thumbs.db 20 | 21 | # Backup files 22 | *.bak 23 | *_old.py 24 | *_backup.py 25 | 26 | # Test outputs 27 | tests/ 28 | debug_*.py 29 | 30 | # Settings backups 31 | settings.json.backup* 32 | 33 | # Runtime configuration 34 | .python_cmd 35 | .clipboard_content.txt 36 | 37 | # User customization 38 | CLAUDE.md 39 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | @PROJECT_INDEX.json 2 | 1. Read all lines of files in the project that are not gitignored 3 | 2. Ultrathink to make a plan for next steps 4 | 3. Present plan to the user for review 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Eric Buess 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PROJECT_INDEX.json: -------------------------------------------------------------------------------- 1 | {"at":"2025-09-10T11:04:22.746713","root":".","tree":[".","\u251c\u2500\u2500 scripts/ (6 files)","\u251c\u2500\u2500 tests/ (4 files)","\u2514\u2500\u2500 README.md"],"stats":{"total_files":9,"total_directories":2,"fully_parsed":{"shell":1,"python":4},"listed_only":{"json":1,"shell":3},"markdown_files":2},"f":{"s/find_python.sh":["s",["find_python:0:()::"]],"s/i_flag_hook.py":["p",["find_project_root:23:()::Find project root by looking for .git or common project markers.","get_last_interactive_size:45:():find_project_root:Get the last remembered -i size from the index.","parse_index_flag:66:(prompt):get_last_interactive_size:Parse -i or -ic flag with optional size.","calculate_files_hash:104:(project_root)::Calculate hash of non-ignored files to detect changes.","should_regenerate_index:141:(project_root, index_path, requested_size_k):calculate_files_hash:Determine if index needs regeneration.","generate_index_at_size:173:(project_root, target_size_k, is_clipboard_mode=False):calculate_files_hash:Generate index at specific token size.","copy_to_clipboard:259:(prompt, index_path)::Copy prompt, instructions, and index to clipboard for external AI.","main:566:():copy_to_clipboard,find_project_root,generate_index_at_size,parse_index_flag,should_regenerate_index:Process UserPromptSubmit hook for -i and -ic flag detection."]],"s/index_utils.py":["p",["extract_function_calls_python:74:(body:str, all_functions:Set[str])>List[str]::Extract function calls from Python code body.","extract_function_calls_javascript:104:(body:str, all_functions:Set[str])>List[str]::Extract function calls from JavaScript/TypeScript code body.","build_call_graph:132:(functions:Dict, classes:Dict)>Tuple[Dict, Dict]::Build bidirectional call graph from extracted functions and methods.","extract_python_signatures:161:(content:str)>Dict[str, Dict]:extract_function_calls_python:Extract Python function and class signatures with full details for all files.","extract_javascript_signatures:545:(content:str)>Dict[str, any]:extract_function_calls_javascript,pos_to_line:Extract JavaScript/TypeScript function and class signatures with full details.","extract_function_calls_shell:906:(body:str, all_functions:Set[str])>List[str]::Extract function calls from shell script body.","extract_shell_signatures:928:(content:str)>Dict[str, any]:extract_function_calls_shell:Extract shell script function signatures and structure.","extract_markdown_structure:1186:(file_path:Path)>Dict[str, List[str]]::Extract headers and architectural hints from markdown files.","infer_file_purpose:1217:(file_path:Path)>Optional[str]::Infer the purpose of a file from its name and location.","infer_directory_purpose:1240:(path:Path, files_within:List[str])>Optional[str]::Infer directory purpose from naming patterns and contents.","get_language_name:1270:(extension:str)>str::Get readable language name from extension.","parse_gitignore:1281:(gitignore_path:Path)>List[str]::Parse a .gitignore file and return list of patterns.","load_gitignore_patterns:1301:(root_path:Path)>Set[str]:parse_gitignore:Load all gitignore patterns from project root and merge with defaults.","matches_gitignore_pattern:1324:(path:Path, patterns:Set[str], root_path:Path)>bool::Check if a path matches any gitignore pattern.","should_index_file:1368:(path:Path, root_path:Path = None)>bool:load_gitignore_patterns,matches_gitignore_pattern:Check if we should index this file.","get_git_files:1388:(root_path:Path)>Optional[List[Path]]::"]],"s/project_index.py":["p",["generate_tree_structure:40:(root_path:Path, max_depth:int = MAX_TREE_DEPTH)>List[str]:add_tree_level,should_include_dir:Generate a compact ASCII tree representation of the directory structure.","build_index:109:(root_dir:str)>Tuple[Dict, int]:generate_tree_structure:Build the enhanced index with architectural awareness.","convert_to_enhanced_dense_format:404:(index:Dict)>Dict:truncate_doc:Convert to enhanced dense format that preserves all AI-relevant information.","compress_if_needed:529:(dense_index:Dict, target_size:int = MAX_INDEX_SIZE)>Dict::Compress dense index further if it exceeds size limit.","print_summary:660:(index:Dict, skipped_count:int)::Print a helpful summary of what was indexed.","main:708:():build_index,compress_if_needed,convert_to_enhanced_dense_format,print_summary:Run the enhanced indexer."]],"s/stop_hook.py":["p",["main:14:()::Stop hook - regenerate index if PROJECT_INDEX.json exists."]]},"g":[["load_gitignore_patterns","parse_gitignore"],["main","print_summary"],["extract_javascript_signatures","extract_function_calls_javascript"],["convert_to_enhanced_dense_format","truncate_doc"],["extract_python_signatures","extract_function_calls_python"],["should_regenerate_index","calculate_files_hash"],["main","should_regenerate_index"],["generate_tree_structure","should_include_dir"],["parse_index_flag","get_last_interactive_size"],["main","compress_if_needed"],["generate_index_at_size","calculate_files_hash"],["main","build_index"],["main","convert_to_enhanced_dense_format"],["main","generate_index_at_size"],["get_last_interactive_size","find_project_root"],["build_index","generate_tree_structure"],["should_index_file","load_gitignore_patterns"],["main","find_project_root"],["generate_tree_structure","add_tree_level"],["main","parse_index_flag"],["should_index_file","matches_gitignore_pattern"],["main","copy_to_clipboard"],["extract_javascript_signatures","pos_to_line"],["extract_shell_signatures","extract_function_calls_shell"]],"d":{".claude-code-ericbuess/CONTEXT_STATE.md":["\ud83d\udd04 Auto-Generated Context State","\ud83d\udccd Current Session","\ud83d\udcca Git Status","\ud83d\udcdd Recently Modified Files","\ud83d\udccc Context Notes"],"README.md":["Project Index for Claude Code","Background","Quick Install","Usage","Or manually create/update the index anytime","What It Does","Three Ways to Use","Small Projects - Direct Reference with `@PROJECT_INDEX.json`","Reference directly in your prompt","Or auto-load in every session by adding to CLAUDE.md:"]},"deps":{"scripts/i_flag_hook.py":["json","sys","os","re","subprocess","hashlib","time","pathlib","datetime","sys","vm_client_network","vm_client","base64","pyperclip"],"scripts/index_utils.py":["re","fnmatch","pathlib","typing","subprocess"],"scripts/project_index.py":["json","os","re","datetime","pathlib","typing","index_utils","index_utils","sys"],"scripts/stop_hook.py":["json","sys","os","subprocess","pathlib"]},"dir_purposes":{"scripts":"Build and utility scripts"},"staleness":1756915462.765223} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Index for Claude Code 2 | 3 | **⚠️ Beta Community Tool - Let Claude Code Fork It!** This is my personal indexing solution that I'm sharing (still in beta). I'm not maintaining this as a product. If you run into issues, have Claude Code help you fix them! Give this repo URL to Claude and ask it to fork, set up, and adapt it for your specific needs. 4 | 5 | ## Background 6 | 7 | I created this tool for myself and talked about it in [this video](https://www.youtube.com/watch?v=JU8BwMe_BWg) and [this X post](https://x.com/EricBuess/status/1955271258939043996). People requested it, so here it is! This works alongside my [Claude Code Docs mirror](https://github.com/ericbuess/claude-code-docs) project. 8 | 9 | I may post videos explaining how I use this project - check [my X/Twitter](https://x.com/EricBuess) for updates and explanations. 10 | 11 | This isn't a product - just a tool that solves Claude Code's architectural blindness for me. Fork it, improve it, make it yours! 12 | 13 | Automatically gives Claude Code architectural awareness of your codebase. Add `-i` to any prompt to generate or update a PROJECT_INDEX.json containing your project's functions, classes, and structure. 14 | 15 | ## Quick Install 16 | 17 | ```bash 18 | curl -fsSL https://raw.githubusercontent.com/ericbuess/claude-code-project-index/main/install.sh | bash 19 | ``` 20 | 21 | ## Usage 22 | 23 | Just add `-i` to any Claude prompt: 24 | 25 | ```bash 26 | claude "fix the auth bug -i" # Auto-creates/uses index (default 50k) 27 | claude "refactor database code -i75" # Target ~75k tokens (if project needs it) 28 | claude "analyze architecture -ic200" # Export up to 200k to clipboard for external AI 29 | 30 | # Or manually create/update the index anytime 31 | /index 32 | ``` 33 | 34 | **Key behaviors:** 35 | - **One-time setup**: Use `-i` once in a project and the index auto-updates forever 36 | - **Size memory**: The number (e.g., 75) is remembered until you specify a new one 37 | - **Auto-maintenance**: Every file change triggers automatic index updates 38 | - **To stop indexing**: Simply delete PROJECT_INDEX.json 39 | 40 | ## What It Does 41 | 42 | PROJECT_INDEX extracts and tracks: 43 | - **Functions & Classes**: Full signatures with parameters and return types 44 | - **Call Relationships**: Which functions call which others 45 | - **File Organization**: All code files respecting .gitignore 46 | - **Directory Structure**: Project layout with file counts 47 | 48 | This helps Claude: 49 | - Find the right code without searching 50 | - Understand dependencies before making changes 51 | - Place new code in the correct location 52 | - Avoid creating duplicate functions 53 | 54 | ## Three Ways to Use 55 | 56 | ### Small Projects - Direct Reference with `@PROJECT_INDEX.json` 57 | ```bash 58 | # Reference directly in your prompt 59 | @PROJECT_INDEX.json what functions call authenticate_user? 60 | 61 | # Or auto-load in every session by adding to CLAUDE.md: 62 | # Add @PROJECT_INDEX.json to your CLAUDE.md file 63 | ``` 64 | 65 | **Best for**: Small projects where the index fits comfortably in context. Gives Claude's main agent direct awareness of your whole project structure. 66 | 67 | ### Medium Projects - Subagent Mode with `-i` flag 68 | ```bash 69 | # Invokes specialized subagent to analyze PROJECT_INDEX.json 70 | claude "refactor the auth system -i" # Default up to 50k tokens 71 | claude "find performance issues -i75" # Target ~75k tokens for more detail 72 | ``` 73 | 74 | **Best for**: Medium to large projects where you want to preserve the main agent's context. The subagent analyzes the index separately and returns only relevant findings. 75 | 76 | The subagent provides: 77 | - Call graph analysis and execution paths 78 | - Dependency mapping and impact analysis 79 | - Dead code detection 80 | - Strategic recommendations on where to make changes 81 | 82 | ### Large Projects - Clipboard Export with `-ic` flag 83 | ```bash 84 | # Export to clipboard for external AI with larger contexts 85 | claude "analyze entire codebase -ic200" # Up to 200k tokens 86 | claude "architecture review -ic800" # Up to 800k tokens 87 | ``` 88 | 89 | **Best for**: Very large projects whose index won't fit in Claude's context window. Export to AI models with larger context windows: 90 | - Gemini Pro (2M tokens) 91 | - Claude models with 200k+ tokens 92 | - ChatGPT 93 | - Grok 94 | 95 | **Note**: I'm not using this on large projects myself yet - this is inspiration/theory. Your mileage may vary. If you hit snags, have Claude Code update it to work for your specific use case! 96 | 97 | ## Token Sizing 98 | 99 | The number after `-i` is a **maximum target**, not a guaranteed size: 100 | 101 | - **Default**: 50k tokens (remembered per project) 102 | - **-i mode range**: 1k to 100k maximum 103 | - **-ic mode range**: 1k to 800k maximum for external AI 104 | - **Actual size**: Often much smaller - only uses what the project needs 105 | - **Compression**: Automatic to fit within limits 106 | 107 | Examples: 108 | - Small project with `-i200`: Might only generate 10k tokens 109 | - Large project with `-i50`: Compresses to fit ~50k target 110 | - Huge project with `-ic500`: Allows up to 500k if needed 111 | 112 | The tool remembers your last `-i` size per project and targets that amount, but actual size depends on your codebase. 113 | 114 | ## Language Support 115 | 116 | **Full parsing** (extracts functions, classes, methods): 117 | - Python (.py) 118 | - JavaScript/TypeScript (.js, .ts, .jsx, .tsx) 119 | - Shell scripts (.sh, .bash) 120 | 121 | **File tracking** (listing only): 122 | - Go, Rust, Java, C/C++, Ruby, PHP, Swift, Kotlin, and 20+ more 123 | 124 | ## Installation Details 125 | 126 | - **Location**: `~/.claude-code-project-index/` 127 | - **Hooks configured**: 128 | - `UserPromptSubmit`: Detects -i flag 129 | - `Stop`: Refreshes index after session 130 | - **Commands**: `/index` for manual creation/update 131 | - **Agent**: `~/.claude/agents/index-analyzer.md` for deep analysis 132 | - **Python**: Automatically finds newest 3.8+ version 133 | 134 | ## Fork & Customize 135 | 136 | **The whole point of this tool is that Claude Code can unbobble it for you!** When you hit issues, don't wait for me - have Claude fix them immediately. This is a community tool meant to be forked and adapted. 137 | 138 | How to customize: 139 | 1. Fork the repo or work with the installed version 140 | 2. Describe your problem to Claude Code 141 | 3. Let Claude modify it for your exact needs 142 | 4. Share your improvements with others 143 | 144 | Common customizations: 145 | ```bash 146 | cd ~/.claude-code-project-index 147 | # Then ask Claude: 148 | # "The indexer hangs on my 5000 file project - fix it" 149 | # "Add support for Ruby and Go files with full parsing" 150 | # "Skip test files and node_modules even if not in .gitignore" 151 | # "Make it work with my monorepo structure" 152 | # "Change compression to handle my specific project better" 153 | ``` 154 | 155 | Remember: Claude Code can rewrite this entire tool in minutes to match your needs. That's the power you have - use it! 156 | 157 | ## Known Issues & Quick Fixes 158 | 159 | **Large projects (>2000 files)**: May timeout or hang during compression 160 | - Fix: Ask Claude "Rewrite compress_if_needed() to handle my 3000 file project" 161 | 162 | **.claude directory**: Already fixed - now excluded from indexing 163 | 164 | **Timeouts**: Default is 30 seconds, may be too short for huge projects 165 | - Fix: Ask Claude "Make timeout dynamic based on file count in i_flag_hook.py" 166 | 167 | For any issue, just describe it to Claude and let it fix the tool for you! 168 | 169 | ## Requirements 170 | 171 | - Python 3.8 or higher 172 | - Claude Code with hooks support 173 | - macOS or Linux 174 | - git and jq (for installation) 175 | 176 | ## Troubleshooting 177 | 178 | **Index not creating?** 179 | - Check Python: `python3 --version` 180 | - Verify hooks: `cat ~/.claude/settings.json | grep i_flag_hook` 181 | - Manual generation: `python3 ~/.claude-code-project-index/scripts/project_index.py` 182 | 183 | **-i flag not working?** 184 | - Run installer again 185 | - Check hooks are configured 186 | - Remove and reinstall if needed 187 | 188 | **Clipboard issues?** 189 | - Install pyperclip: `pip install pyperclip` 190 | - SSH users: Content saved to `.clipboard_content.txt` 191 | - For unlimited clipboard over SSH: [VM Bridge](https://github.com/ericbuess/vm-bridge) 192 | 193 | ## Technical Details 194 | 195 | The index uses a compressed format to save ~50% space: 196 | - Minified JSON (single line) for file storage 197 | - Short keys: `f`→files, `g`→graph, `d`→docs, `deps`→dependencies 198 | - Compact function signatures with line numbers 199 | - Clipboard mode (`-ic`) uses readable formatting for external AI tools 200 | 201 | ## Uninstall 202 | 203 | ```bash 204 | ~/.claude-code-project-index/uninstall.sh 205 | ``` 206 | 207 | --- 208 | Created by [Eric Buess](https://github.com/ericbuess) 209 | - 🐦 [Twitter/X](https://x.com/EricBuess) 210 | - 📺 [YouTube](https://www.youtube.com/@EricBuess) 211 | - 💼 [GitHub](https://github.com/ericbuess) -------------------------------------------------------------------------------- /agents/index-analyzer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: index-analyzer 3 | description: MUST BE USED when analyzing PROJECT_INDEX.json to identify relevant code sections. Provides deep code intelligence through ultrathinking analysis of codebase structure, dependencies, and relationships. 4 | tools: Read, Grep, Glob 5 | --- 6 | 7 | You are a code intelligence specialist that uses ultrathinking to deeply analyze codebases through PROJECT_INDEX.json. 8 | 9 | ## YOUR PRIMARY DIRECTIVE 10 | 11 | When invoked, you MUST: 12 | 1. First, check if PROJECT_INDEX.json exists in the current directory 13 | 2. If it doesn't exist, note this and provide guidance on creating it 14 | 3. If it exists, read and deeply analyze it using ultrathinking 15 | 4. Provide strategic code intelligence for the given request 16 | 17 | ## ULTRATHINKING FRAMEWORK 18 | 19 | For every request, engage in deep ultrathinking about: 20 | 21 | ### Understanding Intent 22 | - What is the user REALLY trying to accomplish? 23 | - Is this debugging, feature development, refactoring, or analysis? 24 | - What level of understanding do they need (surface vs deep)? 25 | - What assumptions might they be making? 26 | 27 | ### Code Relationship Analysis 28 | - **Call Graphs**: Trace complete execution paths using `calls` and `called_by` fields 29 | - **Dependencies**: Map import relationships and module coupling 30 | - **Impact Radius**: What breaks if this changes? What depends on this? 31 | - **Dead Code**: Functions with no `called_by` entries 32 | - **Patterns**: Identify architectural patterns and conventions 33 | 34 | ### Strategic Recommendations 35 | - Which files must be read first for understanding? 36 | - What's the minimum set of files needed for this task? 37 | - What existing patterns should be followed? 38 | - What refactoring opportunities exist? 39 | - Where should new code be placed? 40 | 41 | ## OUTPUT FORMAT 42 | 43 | Structure your analysis as: 44 | 45 | ```markdown 46 | ## 🧠 Code Intelligence Analysis 47 | 48 | ### UNDERSTANDING YOUR REQUEST 49 | [Brief interpretation of what the user wants to achieve] 50 | 51 | ### ESSENTIAL CODE PATHS 52 | [List files and specific functions/classes with line numbers that are central to this task] 53 | - **File**: path/to/file.py 54 | - `function_name()` [line X] - Why this matters 55 | - Called by: [list callers] 56 | - Calls: [list what it calls] 57 | 58 | ### ARCHITECTURAL INSIGHTS 59 | [Deep insights about code structure, patterns, and relationships] 60 | - Current patterns used 61 | - Dependencies to consider 62 | - Potential impacts of changes 63 | 64 | ### STRATEGIC RECOMMENDATIONS 65 | [Specific, actionable guidance] 66 | 1. Start by reading: [specific files in order] 67 | 2. Key understanding needed: [concepts/patterns] 68 | 3. Safe to modify: [what can change] 69 | 4. Avoid changing: [what shouldn't change] 70 | 5. Consider: [opportunities/risks] 71 | 72 | ### IMPACT ANALYSIS 73 | [If changes are being made] 74 | - Direct impacts: [immediate effects] 75 | - Indirect impacts: [cascade effects] 76 | - Testing needs: [what to verify] 77 | ``` 78 | 79 | ## ANALYSIS EXAMPLES 80 | 81 | ### Example 1: Performance Optimization Request 82 | "Make the indexing faster" 83 | 84 | ULTRATHINK: User wants better performance. Need to identify bottlenecks, understand current flow, find optimization opportunities. Check for: 85 | - Redundant operations 86 | - Inefficient algorithms 87 | - I/O patterns 88 | - Caching opportunities 89 | 90 | ### Example 2: Feature Addition 91 | "Add support for Ruby files" 92 | 93 | ULTRATHINK: User wants to extend language support. Need to understand: 94 | - Current parser architecture 95 | - Pattern for adding languages 96 | - Where parsers live 97 | - How to integrate with existing system 98 | 99 | ### Example 3: Debugging 100 | "Why does the hook fail?" 101 | 102 | ULTRATHINK: User experiencing failure. Need to: 103 | - Trace execution path 104 | - Identify error handling 105 | - Find logging/debug points 106 | - Understand failure modes 107 | 108 | ## SPECIAL CONSIDERATIONS 109 | 110 | 1. **Always verify PROJECT_INDEX.json exists** before analysis 111 | 2. **Use line numbers** from the index when referencing code 112 | 3. **Trace call graphs completely** - don't stop at first level 113 | 4. **Consider both directions** - what calls this AND what this calls 114 | 5. **Think about testing** - what needs verification after changes 115 | 6. **Identify patterns** - help maintain consistency 116 | 7. **Find opportunities** - dead code, duplication, refactoring 117 | 118 | ## CRITICAL: ULTRATHINKING REQUIREMENT 119 | 120 | You MUST engage in deep, thorough ultrathinking for every request. Think about: 121 | - Multiple angles and interpretations 122 | - Hidden dependencies and relationships 123 | - Long-term implications 124 | - Best practices and patterns 125 | - Edge cases and error conditions 126 | - Performance implications 127 | - Security considerations 128 | - Maintainability impacts 129 | 130 | Your analysis should demonstrate deep understanding, not surface-level matching. Think like an architect who understands the entire system, not just individual pieces. -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | # Claude Code PROJECT_INDEX Installer 5 | # Installs PROJECT_INDEX to ~/.claude-code-project-index 6 | 7 | echo "Claude Code PROJECT_INDEX Installer" 8 | echo "====================================" 9 | echo "" 10 | 11 | # Fixed installation location 12 | INSTALL_DIR="$HOME/.claude-code-project-index" 13 | 14 | # Detect OS type 15 | if [[ "$OSTYPE" == "darwin"* ]]; then 16 | OS_TYPE="macos" 17 | echo "✓ Detected macOS" 18 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then 19 | OS_TYPE="linux" 20 | echo "✓ Detected Linux" 21 | else 22 | echo "❌ Error: Unsupported OS type: $OSTYPE" 23 | echo "This installer supports macOS and Linux only" 24 | exit 1 25 | fi 26 | 27 | # Check dependencies 28 | echo "" 29 | echo "Checking dependencies..." 30 | 31 | # Check for git and jq 32 | for cmd in git jq; do 33 | if ! command -v "$cmd" &> /dev/null; then 34 | echo "❌ Error: $cmd is required but not installed" 35 | echo "Please install $cmd and try again" 36 | exit 1 37 | fi 38 | done 39 | 40 | # Find Python intelligently 41 | # When running via curl | bash, BASH_SOURCE is not set 42 | if [[ -n "${BASH_SOURCE[0]:-}" ]]; then 43 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 44 | else 45 | # Running via curl | bash - scripts won't be available yet 46 | SCRIPT_DIR="" 47 | fi 48 | 49 | if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/scripts/find_python.sh" ]]; then 50 | PYTHON_CMD=$(bash "$SCRIPT_DIR/scripts/find_python.sh") 51 | else 52 | # Fallback to simple check if find_python.sh doesn't exist yet 53 | if command -v python3 &> /dev/null; then 54 | PYTHON_CMD="python3" 55 | elif command -v python &> /dev/null; then 56 | PYTHON_CMD="python" 57 | else 58 | echo "❌ Error: Python 3.8+ is required but not found" 59 | echo "Please install Python 3.8+ and try again" 60 | exit 1 61 | fi 62 | fi 63 | 64 | if [[ -z "$PYTHON_CMD" ]]; then 65 | exit 1 66 | fi 67 | 68 | echo "✓ All dependencies satisfied" 69 | 70 | # Check if already installed 71 | if [[ -d "$INSTALL_DIR" ]]; then 72 | echo "" 73 | echo "⚠️ Found existing installation at $INSTALL_DIR" 74 | 75 | # Check if we're running interactively or via pipe 76 | if [ -t 0 ]; then 77 | # Interactive mode - can use read 78 | read -p "Remove and reinstall? (y/N): " -n 1 -r 79 | echo 80 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 81 | echo "Installation cancelled" 82 | exit 0 83 | fi 84 | else 85 | # Non-interactive mode (curl | bash) - auto-reinstall 86 | echo "Running in non-interactive mode, removing and reinstalling..." 87 | fi 88 | 89 | echo "Removing existing installation..." 90 | rm -rf "$INSTALL_DIR" 91 | fi 92 | 93 | # Clone or copy repository 94 | echo "" 95 | echo "Installing PROJECT_INDEX..." 96 | 97 | # If we're running from the repo, copy files 98 | # (SCRIPT_DIR already set above during Python detection) 99 | if [[ -f "$SCRIPT_DIR/scripts/project_index.py" || -f "$SCRIPT_DIR/README.md" ]]; then 100 | echo "Installing from local repository..." 101 | 102 | # Create install directory 103 | mkdir -p "$INSTALL_DIR" 104 | 105 | # Copy essential files 106 | cp "$SCRIPT_DIR/install.sh" "$INSTALL_DIR/" 107 | cp "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/" 2>/dev/null || true 108 | cp "$SCRIPT_DIR/scripts/project-index-helper.sh" "$INSTALL_DIR/scripts/" 2>/dev/null || true 109 | cp "$SCRIPT_DIR/README.md" "$INSTALL_DIR/" 2>/dev/null || true 110 | cp "$SCRIPT_DIR/LICENSE" "$INSTALL_DIR/" 2>/dev/null || true 111 | cp "$SCRIPT_DIR/.gitignore" "$INSTALL_DIR/" 2>/dev/null || true 112 | 113 | # Create scripts directory and copy all scripts 114 | mkdir -p "$INSTALL_DIR/scripts" 115 | cp "$SCRIPT_DIR"/*.py "$INSTALL_DIR/scripts/" 2>/dev/null || true 116 | cp "$SCRIPT_DIR/scripts"/*.py "$INSTALL_DIR/scripts/" 2>/dev/null || true 117 | cp "$SCRIPT_DIR/scripts"/*.sh "$INSTALL_DIR/scripts/" 2>/dev/null || true 118 | 119 | # Copy agent files to Claude's agents directory 120 | if [[ -d "$SCRIPT_DIR/agents" ]]; then 121 | mkdir -p "$HOME/.claude/agents" 122 | cp "$SCRIPT_DIR/agents"/*.md "$HOME/.claude/agents/" 2>/dev/null || true 123 | echo " ✓ Agent files installed to ~/.claude/agents/" 124 | fi 125 | 126 | # Remove the old setup script if it was copied 127 | rm -f "$INSTALL_DIR/scripts/setup_hooks.py" 128 | 129 | echo "✓ Files copied to $INSTALL_DIR" 130 | else 131 | # Clone from GitHub 132 | echo "Cloning from GitHub..." 133 | git clone https://github.com/ericbuess/claude-code-project-index.git "$INSTALL_DIR" 134 | 135 | # Move Python files to scripts directory 136 | mkdir -p "$INSTALL_DIR/scripts" 137 | mv "$INSTALL_DIR"/*.py "$INSTALL_DIR/scripts/" 2>/dev/null || true 138 | rm -f "$INSTALL_DIR/scripts/setup_hooks.py" 139 | 140 | # Copy agent files to Claude's agents directory 141 | if [[ -d "$INSTALL_DIR/agents" ]]; then 142 | mkdir -p "$HOME/.claude/agents" 143 | cp "$INSTALL_DIR/agents"/*.md "$HOME/.claude/agents/" 2>/dev/null || true 144 | echo " ✓ Agent files installed to ~/.claude/agents/" 145 | fi 146 | 147 | echo "✓ Repository cloned to $INSTALL_DIR" 148 | fi 149 | 150 | # Make scripts executable 151 | chmod +x "$INSTALL_DIR/install.sh" 2>/dev/null || true 152 | chmod +x "$INSTALL_DIR/uninstall.sh" 2>/dev/null || true 153 | chmod +x "$INSTALL_DIR/scripts/project-index-helper.sh" 2>/dev/null || true 154 | chmod +x "$INSTALL_DIR/scripts/find_python.sh" 2>/dev/null || true 155 | chmod +x "$INSTALL_DIR/scripts/run_python.sh" 2>/dev/null || true 156 | 157 | # Save the Python command for later use 158 | echo "$PYTHON_CMD" > "$INSTALL_DIR/.python_cmd" 159 | echo " ✓ Python command saved: $PYTHON_CMD" 160 | 161 | # Create /index command 162 | echo "" 163 | echo "Creating /index command..." 164 | mkdir -p "$HOME/.claude/commands" 165 | cat > "$HOME/.claude/commands/index.md" << 'EOF' 166 | --- 167 | name: index 168 | description: Create or update PROJECT_INDEX.json for the current project 169 | --- 170 | 171 | # PROJECT_INDEX Command 172 | 173 | This command creates or updates a PROJECT_INDEX.json file that gives Claude architectural awareness of your codebase. 174 | 175 | The indexer script is located at: 176 | `~/.claude-code-project-index/scripts/project_index.py` 177 | 178 | ## What it does 179 | 180 | The PROJECT_INDEX creates a comprehensive map of your project including: 181 | - Directory structure and file organization 182 | - Function and class signatures with type annotations 183 | - Call graphs showing what calls what 184 | - Import dependencies 185 | - Documentation structure 186 | - Directory purposes 187 | 188 | ## Usage 189 | 190 | Simply type `/index` in any project directory to create or update the index. 191 | 192 | ## About the Tool 193 | 194 | **PROJECT_INDEX** is a community tool created by Eric Buess that helps Claude Code understand your project structure better. 195 | 196 | - **GitHub**: https://github.com/ericbuess/claude-code-project-index 197 | - **Purpose**: Prevents code duplication, ensures proper file placement, maintains architectural consistency 198 | - **Philosophy**: Fork and customize for your needs - Claude can modify it instantly 199 | 200 | ## How to Use the Index 201 | 202 | After running `/index`, you can: 203 | 1. Reference it directly: `@PROJECT_INDEX.json what functions call authenticate_user?` 204 | 2. Use with -i flag: `refactor the auth system -i` 205 | 3. Add to CLAUDE.md for auto-loading: `@PROJECT_INDEX.json` 206 | 207 | ## Implementation 208 | 209 | When you run `/index`, Claude will: 210 | 1. Check if PROJECT_INDEX is installed at ~/.claude-code-project-index 211 | 2. Run the indexer script at ~/.claude-code-project-index/scripts/project_index.py to create/update PROJECT_INDEX.json 212 | 3. Provide feedback on what was indexed 213 | 4. The index is then available as PROJECT_INDEX.json 214 | 215 | ## Troubleshooting 216 | 217 | If the index is too large for your project, ask Claude: 218 | "The indexer creates too large an index. Please modify it to only index src/ and lib/ directories" 219 | 220 | For other issues, the tool is designed to be customized - just describe your problem to Claude! 221 | EOF 222 | echo "✓ Created /index command" 223 | 224 | # Update hooks in settings.json 225 | echo "" 226 | echo "Configuring hooks..." 227 | 228 | SETTINGS_FILE="$HOME/.claude/settings.json" 229 | 230 | # Ensure settings.json exists 231 | if [[ ! -f "$SETTINGS_FILE" ]]; then 232 | echo "{}" > "$SETTINGS_FILE" 233 | fi 234 | 235 | # Create a backup 236 | cp "$SETTINGS_FILE" "${SETTINGS_FILE}.backup" 237 | 238 | # Update hooks using jq - removes old PROJECT_INDEX hooks and adds new ones 239 | jq ' 240 | # Initialize hooks if not present 241 | if .hooks == null then .hooks = {} else . end | 242 | 243 | # Initialize UserPromptSubmit if not present (for index-aware mode) 244 | if .hooks.UserPromptSubmit == null then .hooks.UserPromptSubmit = [] else . end | 245 | 246 | # Filter out any existing PROJECT_INDEX UserPromptSubmit hooks, then add the new one 247 | .hooks.UserPromptSubmit = ([.hooks.UserPromptSubmit[] | select( 248 | all(.hooks[]?.command // ""; 249 | contains("i_flag_hook.py") | not) and 250 | all(.hooks[]?.command // ""; 251 | contains("project_index") | not) 252 | )] + [{ 253 | "hooks": [{ 254 | "type": "command", 255 | "command": "'"$HOME"'/.claude-code-project-index/scripts/run_python.sh '"$HOME"'/.claude-code-project-index/scripts/i_flag_hook.py", 256 | "timeout": 20 257 | }] 258 | }]) | 259 | 260 | # Initialize Stop if not present 261 | if .hooks.Stop == null then .hooks.Stop = [] else . end | 262 | 263 | # Filter out any existing PROJECT_INDEX Stop hooks, then add the new one 264 | .hooks.Stop = ([.hooks.Stop[] | select( 265 | all(.hooks[]?.command // ""; 266 | contains("stop_hook.py") | not) and 267 | all(.hooks[]?.command // ""; 268 | contains("reindex_if_needed.py") | not) and 269 | all(.hooks[]?.command // ""; 270 | contains("project_index") | not) 271 | )] + [{ 272 | "matcher": "", 273 | "hooks": [{ 274 | "type": "command", 275 | "command": "'"$HOME"'/.claude-code-project-index/scripts/run_python.sh '"$HOME"'/.claude-code-project-index/scripts/stop_hook.py", 276 | "timeout": 10 277 | }] 278 | }]) 279 | ' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" 280 | 281 | echo "✓ Hooks configured in settings.json" 282 | 283 | # Test installation 284 | echo "" 285 | echo "Testing installation..." 286 | if $PYTHON_CMD "$INSTALL_DIR/scripts/project_index.py" --version 2>/dev/null | grep -q "PROJECT_INDEX"; then 287 | echo "✓ Installation test passed" 288 | else 289 | echo "⚠️ Version check failed, but installation completed" 290 | echo " You can still use /index command normally" 291 | fi 292 | 293 | echo "" 294 | echo "==========================================" 295 | echo "✅ PROJECT_INDEX installed successfully!" 296 | echo "==========================================" 297 | echo "" 298 | echo "📁 Installation location: $INSTALL_DIR" 299 | echo "" 300 | echo "📝 Manual cleanup needed:" 301 | echo " Please remove these old files from ~/.claude/scripts/ if they exist:" 302 | echo " • project_index.py" 303 | echo " • update_index.py" 304 | echo " • reindex_if_needed.py" 305 | echo " • index_utils.py" 306 | echo " • detect_external_changes.py" 307 | echo "" 308 | echo "🚀 Usage:" 309 | echo " • Add -i flag to any prompt for index-aware mode (e.g., 'fix auth bug -i')" 310 | echo " • Use -ic flag to export to clipboard for large context AI models" 311 | echo " • Reference with @PROJECT_INDEX.json when you need architectural awareness" 312 | echo " • The index is created automatically when you use -i flag" 313 | echo "" 314 | echo "📚 For more information, see: $INSTALL_DIR/README.md" -------------------------------------------------------------------------------- /scripts/find_python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Python finder for PROJECT_INDEX 3 | # Finds the newest Python 3.8+ version available 4 | 5 | find_python() { 6 | local min_version_major=3 7 | local min_version_minor=8 8 | # Use simple arrays for compatibility with bash 3.2 (macOS default) 9 | local python_paths=() 10 | local python_versions_list=() 11 | local best_cmd="" 12 | local best_version="0.0" 13 | 14 | echo "🔍 Searching for Python versions..." >&2 15 | 16 | # First, check if we're in a virtual environment 17 | if [[ -n "$VIRTUAL_ENV" ]]; then 18 | if command -v python &> /dev/null; then 19 | local venv_version=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null) 20 | if [[ -n "$venv_version" ]]; then 21 | echo " 📦 Virtual environment detected: Python $venv_version" >&2 22 | echo "python" 23 | return 0 24 | fi 25 | fi 26 | fi 27 | 28 | # List of Python commands to check 29 | # We'll check ALL of these to find the newest version 30 | local python_commands=( 31 | "python3" 32 | "python" 33 | "python3.13" # Latest stable 34 | "python3.12" 35 | "python3.11" 36 | "python3.10" 37 | "python3.9" 38 | "python3.8" # Minimum supported 39 | ) 40 | 41 | # Also check for pythonX.Y in common locations 42 | if [[ "$OSTYPE" == "darwin"* ]]; then 43 | # macOS with Homebrew often has versioned Python commands 44 | for version in {13..8}; do 45 | python_commands+=("python3.$version") 46 | done 47 | # Check Homebrew Python locations 48 | if [[ -d "/opt/homebrew/bin" ]]; then 49 | for py in /opt/homebrew/bin/python3.*; do 50 | if [[ -x "$py" ]]; then 51 | python_commands+=("$py") 52 | fi 53 | done 54 | fi 55 | if [[ -d "/usr/local/bin" ]]; then 56 | for py in /usr/local/bin/python3.*; do 57 | if [[ -x "$py" ]]; then 58 | python_commands+=("$py") 59 | fi 60 | done 61 | fi 62 | fi 63 | 64 | # Check each Python command and store its version 65 | for cmd in "${python_commands[@]}"; do 66 | if command -v "$cmd" &> /dev/null 2>&1 || [[ -x "$cmd" ]]; then 67 | # Get the actual executable path to avoid checking duplicates 68 | local full_path=$(command -v "$cmd" 2>/dev/null || echo "$cmd") 69 | 70 | # Check if this is actually Python 3 and get version 71 | local version_output=$("$cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>/dev/null) 72 | if [[ -n "$version_output" ]]; then 73 | local major=$(echo "$version_output" | cut -d. -f1) 74 | local minor=$(echo "$version_output" | cut -d. -f2) 75 | local micro=$(echo "$version_output" | cut -d. -f3) 76 | local version="${major}.${minor}" 77 | 78 | # Check if version meets minimum requirement 79 | if [[ "$major" -ge "$min_version_major" ]] && [[ "$minor" -ge "$min_version_minor" || "$major" -gt "$min_version_major" ]]; then 80 | # Store this version if we haven't seen this exact Python before 81 | local already_found=0 82 | for i in "${!python_paths[@]}"; do 83 | if [[ "${python_paths[$i]}" == "$full_path" ]]; then 84 | already_found=1 85 | break 86 | fi 87 | done 88 | 89 | if [[ $already_found -eq 0 ]]; then 90 | python_paths+=("$full_path") 91 | python_versions_list+=("$version") 92 | echo " ✓ Found Python $version.$micro at: $full_path" >&2 93 | 94 | # Check if this is the best version so far (shell-native comparison) 95 | # Compare major.minor as separate integers 96 | local best_major=${best_version%%.*} 97 | local best_minor=${best_version##*.} 98 | local curr_major=${version%%.*} 99 | local curr_minor=${version##*.} 100 | 101 | if [[ $curr_major -gt $best_major ]] || \ 102 | [[ $curr_major -eq $best_major && $curr_minor -gt $best_minor ]]; then 103 | best_version="$version" 104 | best_cmd="$cmd" 105 | fi 106 | fi 107 | else 108 | echo " ⚠️ Found $cmd (Python $version) but need Python ${min_version_major}.${min_version_minor}+" >&2 109 | fi 110 | fi 111 | fi 112 | done 113 | 114 | if [[ -n "$best_cmd" ]]; then 115 | echo "" >&2 116 | echo " 🎯 Selected newest version: Python $best_version" >&2 117 | echo " Using: $best_cmd" >&2 118 | echo "$best_cmd" 119 | return 0 120 | else 121 | echo "" >&2 122 | echo "❌ Error: Could not find Python ${min_version_major}.${min_version_minor} or higher" >&2 123 | echo "" >&2 124 | echo "Please install Python 3.8+ using one of these methods:" >&2 125 | echo "" >&2 126 | if [[ "$OSTYPE" == "darwin"* ]]; then 127 | echo " • Homebrew: brew install python@3.12" >&2 128 | echo " • MacPorts: sudo port install python312" >&2 129 | echo " • Download from: https://www.python.org/downloads/" >&2 130 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then 131 | echo " • Debian/Ubuntu: sudo apt-get install python3" >&2 132 | echo " • Fedora: sudo dnf install python3" >&2 133 | echo " • Arch: sudo pacman -S python" >&2 134 | echo " • Download from: https://www.python.org/downloads/" >&2 135 | fi 136 | echo "" >&2 137 | echo "If Python 3.8+ is already installed but not found, you can:" >&2 138 | echo " • Set PYTHON_CMD environment variable: export PYTHON_CMD=/path/to/python3" >&2 139 | echo " • Add Python to your PATH" >&2 140 | echo "" >&2 141 | echo "To prefer a specific Python version over the newest:" >&2 142 | echo " • export PYTHON_CMD=python3.10 (or any specific version)" >&2 143 | return 1 144 | fi 145 | } 146 | 147 | # Check for environment variable override 148 | if [[ -n "$PYTHON_CMD" ]]; then 149 | if command -v "$PYTHON_CMD" &> /dev/null; then 150 | # Verify it meets version requirements (removed 'local' since we're not in a function) 151 | override_version=$("$PYTHON_CMD" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null) 152 | if [[ -n "$override_version" ]]; then 153 | echo " ✓ Using PYTHON_CMD override: $PYTHON_CMD (Python $override_version)" >&2 154 | echo "$PYTHON_CMD" 155 | exit 0 156 | else 157 | echo " ⚠️ PYTHON_CMD set to '$PYTHON_CMD' but doesn't appear to be Python" >&2 158 | fi 159 | else 160 | echo " ⚠️ PYTHON_CMD set to '$PYTHON_CMD' but command not found" >&2 161 | fi 162 | fi 163 | 164 | # If script is run directly (not sourced), find and output Python command 165 | if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 166 | find_python 167 | fi -------------------------------------------------------------------------------- /scripts/i_flag_hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | UserPromptSubmit hook for intelligent PROJECT_INDEX.json analysis. 4 | Detects -i[number] and -ic[number] flags for dynamic index generation. 5 | """ 6 | 7 | import json 8 | import sys 9 | import os 10 | import re 11 | import subprocess 12 | import hashlib 13 | import time 14 | from pathlib import Path 15 | from datetime import datetime 16 | 17 | # Constants 18 | DEFAULT_SIZE_K = 50 # Default 50k tokens 19 | MIN_SIZE_K = 1 # Minimum 1k tokens 20 | CLAUDE_MAX_K = 100 # Max 100k for Claude (leaves room for reasoning) 21 | EXTERNAL_MAX_K = 800 # Max 800k for external AI 22 | 23 | def find_project_root(): 24 | """Find project root by looking for .git or common project markers.""" 25 | current = Path.cwd() 26 | 27 | # First check current directory for project markers 28 | if (current / '.git').exists(): 29 | return current 30 | 31 | # Check for other project markers 32 | project_markers = ['package.json', 'pyproject.toml', 'setup.py', 'Cargo.toml', 'go.mod'] 33 | for marker in project_markers: 34 | if (current / marker).exists(): 35 | return current 36 | 37 | # Search up the tree for .git 38 | for parent in current.parents: 39 | if (parent / '.git').exists(): 40 | return parent 41 | 42 | # Default to current directory 43 | return current 44 | 45 | def get_last_interactive_size(): 46 | """Get the last remembered -i size from the index.""" 47 | try: 48 | project_root = find_project_root() 49 | index_path = project_root / 'PROJECT_INDEX.json' 50 | 51 | if index_path.exists(): 52 | with open(index_path, 'r') as f: 53 | index = json.load(f) 54 | meta = index.get('_meta', {}) 55 | last_size = meta.get('last_interactive_size_k') 56 | 57 | if last_size: 58 | print(f"📝 Using remembered size: {last_size}k", file=sys.stderr) 59 | return last_size 60 | except: 61 | pass 62 | 63 | # Fall back to default 64 | return DEFAULT_SIZE_K 65 | 66 | def parse_index_flag(prompt): 67 | """Parse -i or -ic flag with optional size.""" 68 | # Pattern matches -i[number] or -ic[number] 69 | match = re.search(r'-i(c?)(\d+)?(?:\s|$)', prompt) 70 | 71 | if not match: 72 | return None, None, prompt 73 | 74 | clipboard_mode = match.group(1) == 'c' 75 | 76 | # If no explicit size provided, check for remembered size 77 | if match.group(2): 78 | size_k = int(match.group(2)) 79 | else: 80 | # For -i without size, try to use last remembered size 81 | if not clipboard_mode: 82 | size_k = get_last_interactive_size() 83 | else: 84 | # For -ic, always use default 85 | size_k = DEFAULT_SIZE_K 86 | 87 | # Validate size limits 88 | if size_k < MIN_SIZE_K: 89 | print(f"⚠️ Minimum size is {MIN_SIZE_K}k, using {MIN_SIZE_K}k", file=sys.stderr) 90 | size_k = MIN_SIZE_K 91 | 92 | if not clipboard_mode and size_k > CLAUDE_MAX_K: 93 | print(f"⚠️ Claude max is {CLAUDE_MAX_K}k (need buffer for reasoning), using {CLAUDE_MAX_K}k", file=sys.stderr) 94 | size_k = CLAUDE_MAX_K 95 | elif clipboard_mode and size_k > EXTERNAL_MAX_K: 96 | print(f"⚠️ Maximum size is {EXTERNAL_MAX_K}k, using {EXTERNAL_MAX_K}k", file=sys.stderr) 97 | size_k = EXTERNAL_MAX_K 98 | 99 | # Clean prompt (remove flag) 100 | cleaned_prompt = re.sub(r'-ic?\d*\s*', '', prompt).strip() 101 | 102 | return size_k, clipboard_mode, cleaned_prompt 103 | 104 | def calculate_files_hash(project_root): 105 | """Calculate hash of non-ignored files to detect changes.""" 106 | try: 107 | # Use git ls-files to get non-ignored files 108 | result = subprocess.run( 109 | ['git', 'ls-files', '--cached', '--others', '--exclude-standard'], 110 | cwd=str(project_root), 111 | capture_output=True, 112 | text=True, 113 | timeout=5 114 | ) 115 | 116 | if result.returncode == 0: 117 | files = result.stdout.strip().split('\n') if result.stdout.strip() else [] 118 | else: 119 | # Fallback to manual file discovery 120 | files = [] 121 | for file_path in project_root.rglob('*'): 122 | if file_path.is_file() and not any(part.startswith('.') for part in file_path.parts): 123 | files.append(str(file_path.relative_to(project_root))) 124 | 125 | # Hash file paths and modification times 126 | hasher = hashlib.sha256() 127 | for file_path in sorted(files): 128 | full_path = project_root / file_path 129 | if full_path.exists(): 130 | try: 131 | mtime = str(full_path.stat().st_mtime) 132 | hasher.update(f"{file_path}:{mtime}".encode()) 133 | except: 134 | pass 135 | 136 | return hasher.hexdigest()[:16] 137 | except Exception as e: 138 | print(f"Warning: Could not calculate files hash: {e}", file=sys.stderr) 139 | return "unknown" 140 | 141 | def should_regenerate_index(project_root, index_path, requested_size_k): 142 | """Determine if index needs regeneration.""" 143 | if not index_path.exists(): 144 | return True, "No index exists" 145 | 146 | try: 147 | # Read metadata 148 | with open(index_path, 'r') as f: 149 | index = json.load(f) 150 | meta = index.get('_meta', {}) 151 | 152 | # Get last generation info 153 | last_target = meta.get('target_size_k', 0) 154 | last_files_hash = meta.get('files_hash', '') 155 | 156 | # Check if files changed 157 | current_files_hash = calculate_files_hash(project_root) 158 | if current_files_hash != last_files_hash and current_files_hash != "unknown": 159 | return True, f"Files changed since last index" 160 | 161 | # Check if different size requested 162 | if abs(requested_size_k - last_target) > 2: # Allow 2k tolerance 163 | return True, f"Different size requested ({requested_size_k}k vs {last_target}k)" 164 | 165 | # Use existing index 166 | actual_k = meta.get('actual_size_k', last_target) 167 | return False, f"Using cached index ({actual_k}k actual, {last_target}k target)" 168 | 169 | except Exception as e: 170 | print(f"Warning: Could not read index metadata: {e}", file=sys.stderr) 171 | return True, "Could not read index metadata" 172 | 173 | def generate_index_at_size(project_root, target_size_k, is_clipboard_mode=False): 174 | """Generate index at specific token size.""" 175 | print(f"🎯 Generating {target_size_k}k token index...", file=sys.stderr) 176 | 177 | # Find indexer script 178 | local_indexer = Path(__file__).parent / 'project_index.py' 179 | system_indexer = Path.home() / '.claude-code-project-index' / 'scripts' / 'project_index.py' 180 | 181 | indexer_path = local_indexer if local_indexer.exists() else system_indexer 182 | 183 | if not indexer_path.exists(): 184 | print("⚠️ PROJECT_INDEX.json indexer not found", file=sys.stderr) 185 | return False 186 | 187 | try: 188 | # Find Python command 189 | python_cmd_file = Path.home() / '.claude-code-project-index' / '.python_cmd' 190 | if python_cmd_file.exists(): 191 | python_cmd = python_cmd_file.read_text().strip() 192 | else: 193 | python_cmd = sys.executable 194 | 195 | # Pass target size as environment variable 196 | env = os.environ.copy() 197 | env['INDEX_TARGET_SIZE_K'] = str(target_size_k) 198 | 199 | result = subprocess.run( 200 | [python_cmd, str(indexer_path)], 201 | cwd=str(project_root), 202 | capture_output=True, 203 | text=True, 204 | timeout=30, # 30 seconds should be plenty for most projects 205 | env=env 206 | ) 207 | 208 | if result.returncode == 0: 209 | # Update metadata with target size and hash 210 | index_path = project_root / 'PROJECT_INDEX.json' 211 | if index_path.exists(): 212 | with open(index_path, 'r') as f: 213 | index = json.load(f) 214 | 215 | # Measure actual size 216 | index_str = json.dumps(index, indent=2) 217 | actual_tokens = len(index_str) // 4 # Rough estimate: 4 chars = 1 token 218 | actual_size_k = actual_tokens // 1000 219 | 220 | # Add/update metadata 221 | if '_meta' not in index: 222 | index['_meta'] = {} 223 | 224 | metadata_update = { 225 | 'generated_at': time.time(), 226 | 'target_size_k': target_size_k, 227 | 'actual_size_k': actual_size_k, 228 | 'files_hash': calculate_files_hash(project_root), 229 | 'compression_ratio': f"{(actual_size_k/target_size_k)*100:.1f}%" if target_size_k > 0 else "N/A" 230 | } 231 | 232 | # Remember -i size for next time (but not -ic) 233 | if not is_clipboard_mode: 234 | metadata_update['last_interactive_size_k'] = target_size_k 235 | print(f"💾 Remembering size {target_size_k}k for next -i", file=sys.stderr) 236 | 237 | index['_meta'].update(metadata_update) 238 | 239 | # Save updated index 240 | with open(index_path, 'w') as f: 241 | json.dump(index, f, indent=2) 242 | 243 | print(f"✅ Created PROJECT_INDEX.json ({actual_size_k}k actual, {target_size_k}k target)", file=sys.stderr) 244 | return True 245 | else: 246 | print("⚠️ Index file not created", file=sys.stderr) 247 | return False 248 | else: 249 | print(f"⚠️ Failed to create index: {result.stderr}", file=sys.stderr) 250 | return False 251 | 252 | except subprocess.TimeoutExpired: 253 | print("⚠️ Index creation timed out", file=sys.stderr) 254 | return False 255 | except Exception as e: 256 | print(f"⚠️ Error creating index: {e}", file=sys.stderr) 257 | return False 258 | 259 | def copy_to_clipboard(prompt, index_path): 260 | """Copy prompt, instructions, and index to clipboard for external AI.""" 261 | try: 262 | # Try VM Bridge first (works with any size over mosh) 263 | vm_bridge_available = False 264 | bridge_client = None 265 | 266 | try: 267 | import sys 268 | # Try multiple VM Bridge locations in order of preference 269 | vm_bridge_paths = [ 270 | os.path.expanduser('~/.claude-ericbuess/tools/vm-bridge'), # New standard location 271 | '/home/ericbuess/Projects/vm-bridge', # Legacy project location 272 | os.path.expanduser('~/.local/lib/python/vm_bridge') # Old tunnel location 273 | ] 274 | 275 | # Try network version first (no tunnel needed) 276 | for bridge_path in vm_bridge_paths: 277 | if os.path.exists(bridge_path): 278 | sys.path.insert(0, bridge_path) 279 | try: 280 | from vm_client_network import VMBridgeClient as NetworkClient 281 | 282 | # Try to auto-detect or use known Mac IP 283 | for mac_ip in ['10.211.55.2', '10.211.55.1', '192.168.1.1']: 284 | try: 285 | test_client = NetworkClient(host=mac_ip) 286 | if test_client.is_daemon_running(): 287 | bridge_client = test_client 288 | print(f"🌉 VM Bridge network daemon detected at {mac_ip}", file=sys.stderr) 289 | vm_bridge_available = True 290 | break 291 | except: 292 | continue 293 | 294 | if vm_bridge_available: 295 | break 296 | except ImportError: 297 | # Try next path 298 | continue 299 | 300 | # Fall back to localhost tunnel version if network not available 301 | if not vm_bridge_available: 302 | for bridge_path in vm_bridge_paths: 303 | if os.path.exists(bridge_path): 304 | sys.path.insert(0, bridge_path) 305 | try: 306 | from vm_client import VMBridgeClient 307 | bridge_client = VMBridgeClient() 308 | if bridge_client.is_daemon_running(): 309 | print("🌉 VM Bridge tunnel daemon detected", file=sys.stderr) 310 | vm_bridge_available = True 311 | break 312 | except ImportError: 313 | continue 314 | 315 | except ImportError: 316 | vm_bridge_available = False 317 | # Create clipboard-specific instructions (no tools, no subagent references) 318 | clipboard_instructions = """You are analyzing a codebase index to help identify relevant files and code sections. 319 | 320 | ## YOUR TASK 321 | Analyze the PROJECT_INDEX.json below to identify the most relevant code sections for the user's request. 322 | The index contains file structures, function signatures, call graphs, and dependencies. 323 | 324 | ## WHAT TO LOOK FOR 325 | - Identify specific files and functions related to the request 326 | - Trace call graphs to understand code flow 327 | - Note dependencies and relationships 328 | - Consider architectural patterns 329 | 330 | ## IMPORTANT: RESPONSE FORMAT 331 | Your response will be copied and pasted to Claude Code. Format your response as: 332 | 333 | ### 📍 RELEVANT CODE LOCATIONS 334 | 335 | **Primary Files to Examine:** 336 | - `path/to/file.py` - [Why relevant] 337 | - `function_name()` (line X) - [What it does] 338 | - Called by: [list any callers] 339 | - Calls: [list what it calls] 340 | 341 | **Related Files:** 342 | - `path/to/related.py` - [Connection to task] 343 | 344 | ### 🔍 KEY INSIGHTS 345 | - [Architectural patterns observed] 346 | - [Dependencies to consider] 347 | - [Potential challenges or gotchas] 348 | 349 | ### 💡 RECOMMENDATIONS 350 | - Start by examining: [specific file] 351 | - Focus on: [specific functions/classes] 352 | - Consider: [any special considerations] 353 | 354 | Do NOT include the original user prompt in your response. 355 | Focus on providing actionable file locations and insights.""" 356 | 357 | # Load index 358 | with open(index_path, 'r') as f: 359 | index = json.load(f) 360 | 361 | # Build clipboard content 362 | clipboard_content = f"""# Codebase Analysis Request 363 | 364 | ## Task for You 365 | {prompt} 366 | 367 | ## Instructions 368 | {clipboard_instructions} 369 | 370 | ## PROJECT_INDEX.json 371 | {json.dumps(index, indent=2)} 372 | """ 373 | 374 | # Try to copy to clipboard 375 | clipboard_success = False 376 | 377 | # Try VM Bridge first if available (works with any size over mosh) 378 | if vm_bridge_available: 379 | try: 380 | if bridge_client.copy_to_clipboard(clipboard_content): 381 | print(f"✅ Copied to Mac clipboard via VM Bridge ({len(clipboard_content)} chars)", file=sys.stderr) 382 | print(f"🌉 No size limits with VM Bridge!", file=sys.stderr) 383 | 384 | # Also notify on Mac 385 | bridge_client.notify(f"Clipboard updated: {len(clipboard_content)} chars from VM") 386 | 387 | # Save to file as backup 388 | fallback_path = Path.cwd() / '.clipboard_content.txt' 389 | with open(fallback_path, 'w') as f: 390 | f.write(clipboard_content) 391 | print(f"📁 Also saved to {fallback_path} as backup", file=sys.stderr) 392 | 393 | return ('vm_bridge', len(clipboard_content)) 394 | except Exception as e: 395 | print(f"⚠️ VM Bridge failed: {e}", file=sys.stderr) 396 | # Fall through to other methods 397 | 398 | # Check if we're in an SSH session (clipboard won't work across SSH) 399 | is_ssh = os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_CLIENT') 400 | 401 | # For SSH sessions, try OSC 52 or other methods 402 | if is_ssh: 403 | fallback_path = Path.cwd() / '.clipboard_content.txt' 404 | with open(fallback_path, 'w') as f: 405 | f.write(clipboard_content) 406 | 407 | # Import base64 at the beginning for all methods 408 | import base64 409 | 410 | # Try multiple clipboard methods for SSH sessions 411 | clipboard_success = False 412 | 413 | # Check content size first - OSC 52 has limits, especially over mosh 414 | content_size = len(clipboard_content) 415 | # Testing shows mosh/tmux cuts off at ~12KB, so stay safely under that 416 | mosh_limit = 11000 # Just under the 12KB cutoff we observed 417 | 418 | if content_size <= mosh_limit: 419 | # Small enough for OSC 52 - try to send directly to clipboard 420 | try: 421 | # Base64 encode and remove newlines 422 | b64_content = base64.b64encode(clipboard_content.encode('utf-8')).decode('ascii') 423 | 424 | # Get the correct TTY device 425 | tty_device = None 426 | is_tmux = os.environ.get('TMUX') 427 | 428 | if is_tmux: 429 | # Inside tmux: get the client tty 430 | try: 431 | result = subprocess.run(['tmux', 'display-message', '-p', '#{client_tty}'], 432 | capture_output=True, text=True, check=True) 433 | tty_device = result.stdout.strip() 434 | except: 435 | tty_device = "/dev/tty" 436 | else: 437 | tty_device = "/dev/tty" 438 | 439 | # Send OSC 52 sequence with proper format 440 | if is_tmux: 441 | # Inside tmux: use DCS passthrough (this is the KEY!) 442 | osc52_sequence = f"\033Ptmux;\033\033]52;c;{b64_content}\007\033\\" 443 | else: 444 | # Outside tmux: use standard OSC 52 445 | osc52_sequence = f"\033]52;c;{b64_content}\007" 446 | 447 | # Write directly to TTY device (not stderr) 448 | try: 449 | with open(tty_device, 'w') as tty: 450 | tty.write(osc52_sequence) 451 | tty.flush() 452 | clipboard_success = True 453 | print(f"✅ Sent to Mac clipboard via OSC 52 ({content_size} chars)", file=sys.stderr) 454 | except PermissionError: 455 | # Fallback to stderr if can't open TTY 456 | sys.stderr.write(osc52_sequence) 457 | sys.stderr.flush() 458 | clipboard_success = True 459 | print(f"✅ Sent to Mac clipboard via OSC 52 ({content_size} chars)", file=sys.stderr) 460 | 461 | except Exception as e: 462 | print(f"⚠️ OSC 52 failed: {e}", file=sys.stderr) 463 | else: 464 | # Too large for mosh/tmux's ~12KB limit - use alternative methods 465 | # Testing shows clipboard gets truncated at ~12KB over mosh 466 | print(f"📋 Content exceeds mosh/tmux's 12KB limit ({content_size} chars)", file=sys.stderr) 467 | 468 | # Load into tmux buffer for local access 469 | try: 470 | proc = subprocess.Popen(['tmux', 'load-buffer', '-'], stdin=subprocess.PIPE, 471 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 472 | proc.communicate(clipboard_content.encode('utf-8')) 473 | if proc.returncode == 0: 474 | print(f"✅ Loaded into tmux buffer", file=sys.stderr) 475 | 476 | # Try to trigger automatic Mac clipboard sync 477 | # This runs a command on the tmux client (Mac) side 478 | sync_cmd = f"ssh {os.environ.get('USER', 'user')}@10.211.55.4 'cat ~/Projects/claude-code-project-index/.clipboard_content.txt' | pbcopy" 479 | tmux_run = f"tmux run-shell '{sync_cmd}'" 480 | 481 | try: 482 | subprocess.run(['tmux', 'run-shell', sync_cmd], 483 | capture_output=True, timeout=2) 484 | print(f"🚀 Attempting automatic clipboard sync to Mac...", file=sys.stderr) 485 | except: 486 | pass 487 | except: 488 | pass 489 | 490 | print(f"", file=sys.stderr) 491 | print(f"To manually copy to Mac clipboard, run this on your Mac:", file=sys.stderr) 492 | print(f" ssh {os.environ.get('USER', 'user')}@10.211.55.4 'cat ~/Projects/claude-code-project-index/.clipboard_content.txt' | pbcopy", file=sys.stderr) 493 | print(f"", file=sys.stderr) 494 | print(f"ℹ️ Mosh/tmux limits clipboard to ~12KB. For larger content, consider:", file=sys.stderr) 495 | print(f" - Using SSH instead of mosh for this operation", file=sys.stderr) 496 | print(f" - Or using the manual command above", file=sys.stderr) 497 | 498 | # Also try tmux buffer for local pasting 499 | try: 500 | proc = subprocess.Popen(['tmux', 'load-buffer', '-'], stdin=subprocess.PIPE, 501 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 502 | proc.communicate(clipboard_content.encode('utf-8')) 503 | if proc.returncode == 0: 504 | print(f"✅ Loaded into tmux buffer (use prefix + ] to paste)", file=sys.stderr) 505 | except: 506 | pass 507 | 508 | print(f"📁 Full content saved to {fallback_path}", file=sys.stderr) 509 | 510 | if clipboard_success: 511 | return ('ssh_clipboard', str(fallback_path)) 512 | else: 513 | return ('ssh_file_large', str(fallback_path)) 514 | 515 | # First try xclip directly (most reliable for Linux) 516 | try: 517 | result = subprocess.run(['which', 'xclip'], capture_output=True) 518 | if result.returncode == 0: 519 | # Use xclip with a virtual display if needed 520 | env = os.environ.copy() 521 | if not env.get('DISPLAY'): 522 | # Check if Xvfb is running on :99 523 | xvfb_check = subprocess.run(['pgrep', '-f', 'Xvfb.*:99'], capture_output=True) 524 | if xvfb_check.returncode != 0: 525 | # Start Xvfb if not running 526 | subprocess.Popen(['Xvfb', ':99', '-screen', '0', '1024x768x24'], 527 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 528 | time.sleep(0.5) 529 | env['DISPLAY'] = ':99' 530 | 531 | # Copy to clipboard using xclip 532 | proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], 533 | stdin=subprocess.PIPE, env=env, 534 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 535 | proc.communicate(clipboard_content.encode('utf-8')) 536 | if proc.returncode == 0: 537 | clipboard_success = True 538 | print(f"✅ Copied to clipboard via xclip: {len(clipboard_content)} chars", file=sys.stderr) 539 | print(f"📋 Ready to paste into Gemini, Claude.ai, ChatGPT, or other AI", file=sys.stderr) 540 | return ('clipboard', len(clipboard_content)) 541 | except: 542 | pass 543 | 544 | # Fallback to pyperclip if xclip didn't work 545 | if not clipboard_success: 546 | try: 547 | import pyperclip 548 | pyperclip.copy(clipboard_content) 549 | print(f"✅ Copied to clipboard via pyperclip: {len(clipboard_content)} chars", file=sys.stderr) 550 | print(f"📋 Ready to paste into Gemini, Claude.ai, ChatGPT, or other AI", file=sys.stderr) 551 | return ('clipboard', len(clipboard_content)) 552 | except (ImportError, Exception) as e: 553 | pass 554 | 555 | # Final fallback to file if clipboard methods failed 556 | if not clipboard_success: 557 | fallback_path = Path.cwd() / '.clipboard_content.txt' 558 | with open(fallback_path, 'w') as f: 559 | f.write(clipboard_content) 560 | print(f"✅ Saved to {fallback_path} (copy manually)", file=sys.stderr) 561 | return ('file', str(fallback_path)) 562 | except Exception as e: 563 | print(f"⚠️ Error preparing clipboard content: {e}", file=sys.stderr) 564 | return ('error', str(e)) 565 | 566 | def main(): 567 | """Process UserPromptSubmit hook for -i and -ic flag detection.""" 568 | try: 569 | # Read hook input 570 | input_data = json.load(sys.stdin) 571 | prompt = input_data.get('prompt', '') 572 | 573 | # Parse flag 574 | size_k, clipboard_mode, cleaned_prompt = parse_index_flag(prompt) 575 | 576 | if size_k is None: 577 | # No index flag, let prompt proceed normally 578 | sys.exit(0) 579 | 580 | # Find project root 581 | project_root = find_project_root() 582 | index_path = project_root / 'PROJECT_INDEX.json' 583 | 584 | # Check if regeneration needed 585 | should_regen, reason = should_regenerate_index(project_root, index_path, size_k) 586 | 587 | if should_regen: 588 | print(f"🔄 Regenerating index: {reason}", file=sys.stderr) 589 | if not generate_index_at_size(project_root, size_k, clipboard_mode): 590 | print("⚠️ Proceeding without PROJECT_INDEX.json", file=sys.stderr) 591 | sys.exit(0) 592 | else: 593 | print(f"✅ {reason}", file=sys.stderr) 594 | 595 | # Handle clipboard mode 596 | if clipboard_mode: 597 | copy_result = copy_to_clipboard(cleaned_prompt, index_path) 598 | if copy_result[0] == 'vm_bridge': 599 | # Successfully copied via VM Bridge 600 | output = { 601 | "hookSpecificOutput": { 602 | "hookEventName": "UserPromptSubmit", 603 | "additionalContext": f""" 604 | 🌉 Clipboard Mode - VM Bridge Success! 605 | 606 | ✅ Index copied to Mac clipboard via VM Bridge ({copy_result[1]} chars). 607 | 🚀 No size limits with this method! 608 | 📁 Also saved to: .clipboard_content.txt 609 | 610 | Paste directly into external AI (Gemini, Claude.ai, ChatGPT) for analysis. 611 | 612 | **CRITICAL INSTRUCTION FOR CLAUDE**: STOP! Do NOT proceed with the original request. The user wants to use an external AI for analysis. You should: 613 | 1. ONLY acknowledge that the content was copied to clipboard 614 | 2. WAIT for the user to paste the external AI's response 615 | 3. DO NOT attempt to answer or work on: "{cleaned_prompt}" 616 | 617 | Simply respond with something like: "✅ Index copied to clipboard for external AI analysis. Please paste the response here when ready." 618 | 619 | User's request (DO NOT ANSWER): {cleaned_prompt} 620 | """ 621 | } 622 | } 623 | elif copy_result[0] == 'clipboard': 624 | # Successfully copied to clipboard 625 | output = { 626 | "hookSpecificOutput": { 627 | "hookEventName": "UserPromptSubmit", 628 | "additionalContext": f""" 629 | 📋 Clipboard Mode Activated 630 | 631 | Index and instructions copied to clipboard ({size_k}k tokens, {copy_result[1]} chars). 632 | Paste into external AI (Gemini, Claude.ai, ChatGPT) for analysis. 633 | 634 | **CRITICAL INSTRUCTION FOR CLAUDE**: STOP! Do NOT proceed with the original request. The user wants to use an external AI for analysis. You should: 635 | 1. ONLY acknowledge that the content was copied to clipboard 636 | 2. WAIT for the user to paste the external AI's response 637 | 3. DO NOT attempt to answer or work on: "{cleaned_prompt}" 638 | 639 | Simply respond with something like: "✅ Index copied to clipboard for external AI analysis. Please paste the response here when ready." 640 | 641 | User's request (DO NOT ANSWER): {cleaned_prompt} 642 | """ 643 | } 644 | } 645 | elif copy_result[0] == 'ssh_clipboard': 646 | # SSH session with successful clipboard copy 647 | output = { 648 | "hookSpecificOutput": { 649 | "hookEventName": "UserPromptSubmit", 650 | "additionalContext": f""" 651 | 📋 Clipboard Mode - Mac Clipboard Success! 652 | 653 | ✅ Index copied to your Mac's clipboard via pbcopy ({size_k}k tokens). 654 | 📁 Also saved to: {copy_result[1]} 655 | 656 | Paste directly into external AI (Gemini, Claude.ai, ChatGPT) for analysis. 657 | 658 | **CRITICAL INSTRUCTION FOR CLAUDE**: STOP! Do NOT proceed with the original request. The user wants to use an external AI for analysis. You should: 659 | 1. ONLY acknowledge that the content was copied to clipboard 660 | 2. WAIT for the user to paste the external AI's response 661 | 3. DO NOT attempt to answer or work on: "{cleaned_prompt}" 662 | 663 | Simply respond with something like: "✅ Index copied to clipboard for external AI analysis. Please paste the response here when ready." 664 | 665 | User's request (DO NOT ANSWER): {cleaned_prompt} 666 | """ 667 | } 668 | } 669 | elif copy_result[0] == 'ssh_file_large': 670 | # SSH session with large content - manual copy needed 671 | output = { 672 | "hookSpecificOutput": { 673 | "hookEventName": "UserPromptSubmit", 674 | "additionalContext": f""" 675 | 📋 Clipboard Mode - Content Too Large for Auto-Copy 676 | 677 | Index saved to: {copy_result[1]} ({size_k}k tokens). 678 | ⚠️ Content exceeds mosh/OSC 52 limit (7.5KB) for automatic clipboard. 679 | 680 | To copy the full index to your Mac clipboard, run this command on your Mac: 681 | ssh {os.environ.get('USER', 'user')}@10.211.55.4 'cat ~/Projects/claude-code-project-index/.clipboard_content.txt' | pbcopy 682 | 683 | Then paste into external AI (Gemini, Claude.ai, ChatGPT) for analysis. 684 | 685 | **CRITICAL INSTRUCTION FOR CLAUDE**: STOP! Do NOT proceed with the original request. The user wants to use an external AI for analysis. You should: 686 | 1. ONLY acknowledge that the content was copied to clipboard 687 | 2. WAIT for the user to paste the external AI's response 688 | 3. DO NOT attempt to answer or work on: "{cleaned_prompt}" 689 | 690 | Simply respond with something like: "✅ Index copied to clipboard for external AI analysis. Please paste the response here when ready." 691 | 692 | User's request (DO NOT ANSWER): {cleaned_prompt} 693 | """ 694 | } 695 | } 696 | elif copy_result[0] == 'file': 697 | # Saved to file fallback 698 | output = { 699 | "hookSpecificOutput": { 700 | "hookEventName": "UserPromptSubmit", 701 | "additionalContext": f""" 702 | 📁 Clipboard Mode (File Fallback) 703 | 704 | Index and instructions saved to: {copy_result[1]} ({size_k}k tokens). 705 | ⚠️ pyperclip not installed - content saved to file instead. 706 | 707 | To copy: cat {copy_result[1]} | pbcopy # macOS 708 | cat {copy_result[1]} | xclip # Linux 709 | 710 | Then paste into external AI (Gemini, Claude.ai, ChatGPT) for analysis. 711 | 712 | **CRITICAL INSTRUCTION FOR CLAUDE**: STOP! Do NOT proceed with the original request. The user wants to use an external AI for analysis. You should: 713 | 1. ONLY acknowledge that the content was copied to clipboard 714 | 2. WAIT for the user to paste the external AI's response 715 | 3. DO NOT attempt to answer or work on: "{cleaned_prompt}" 716 | 717 | Simply respond with something like: "✅ Index copied to clipboard for external AI analysis. Please paste the response here when ready." 718 | 719 | User's request (DO NOT ANSWER): {cleaned_prompt} 720 | """ 721 | } 722 | } 723 | else: 724 | # Error case 725 | output = { 726 | "hookSpecificOutput": { 727 | "hookEventName": "UserPromptSubmit", 728 | "additionalContext": f""" 729 | ❌ Clipboard Mode Failed 730 | 731 | Error: {copy_result[1]} 732 | 733 | Please check the error and try again. 734 | User's request (DO NOT ANSWER): {cleaned_prompt} 735 | """ 736 | } 737 | } 738 | else: 739 | # Standard mode - prepare for subagent 740 | output = { 741 | "hookSpecificOutput": { 742 | "hookEventName": "UserPromptSubmit", 743 | "additionalContext": f""" 744 | ## 🎯 Index-Aware Mode Activated 745 | 746 | Generated/loaded {size_k}k token index. 747 | 748 | **IMPORTANT**: You MUST use the index-analyzer subagent to analyze the codebase structure before proceeding with the request. 749 | 750 | Use it like this: 751 | "I'll analyze the codebase structure to understand the relevant code sections for your request." 752 | 753 | Then explicitly invoke: "Using the index-analyzer subagent to analyze PROJECT_INDEX.json..." 754 | 755 | The subagent will provide deep code intelligence including: 756 | - Essential code paths and dependencies 757 | - Call graphs and impact analysis 758 | - Architectural insights and patterns 759 | - Strategic recommendations 760 | 761 | Original request (without -i flag): {cleaned_prompt} 762 | 763 | PROJECT_INDEX.json location: {index_path} 764 | """ 765 | } 766 | } 767 | 768 | print(json.dumps(output)) 769 | sys.exit(0) 770 | 771 | except json.JSONDecodeError as e: 772 | print(f"Error: Invalid JSON input: {e}", file=sys.stderr) 773 | sys.exit(1) 774 | except Exception as e: 775 | print(f"Hook error: {e}", file=sys.stderr) 776 | sys.exit(1) 777 | 778 | if __name__ == '__main__': 779 | main() -------------------------------------------------------------------------------- /scripts/index_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Shared utilities for project indexing. 4 | Contains common functionality used by both project_index.py and hook scripts. 5 | """ 6 | 7 | import re 8 | import fnmatch 9 | from pathlib import Path 10 | from typing import Dict, List, Optional, Set, Tuple 11 | 12 | # What to ignore (sensible defaults) 13 | IGNORE_DIRS = { 14 | '.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env', 15 | 'build', 'dist', '.next', 'target', '.pytest_cache', 'coverage', 16 | '.idea', '.vscode', '__pycache__', '.DS_Store', 'eggs', '.eggs', 17 | '.claude' # Exclude Claude configuration directory 18 | } 19 | 20 | # Languages we can fully parse (extract functions/classes) 21 | PARSEABLE_LANGUAGES = { 22 | '.py': 'python', 23 | '.js': 'javascript', 24 | '.ts': 'typescript', 25 | '.jsx': 'javascript', 26 | '.tsx': 'typescript', 27 | '.sh': 'shell', 28 | '.bash': 'shell' 29 | } 30 | 31 | # All code file extensions we recognize 32 | CODE_EXTENSIONS = { 33 | # Currently parsed 34 | '.py', '.js', '.ts', '.jsx', '.tsx', 35 | # Common languages (listed but not parsed yet) 36 | '.go', '.rs', '.java', '.c', '.cpp', '.cc', '.cxx', 37 | '.h', '.hpp', '.rb', '.php', '.swift', '.kt', '.scala', 38 | '.cs', '.sh', '.bash', '.sql', '.r', '.R', '.lua', '.m', 39 | '.ex', '.exs', '.jl', '.dart', '.vue', '.svelte', 40 | # Configuration and data files 41 | '.json', '.html', '.css' 42 | } 43 | 44 | # Markdown files to analyze 45 | MARKDOWN_EXTENSIONS = {'.md', '.markdown', '.rst'} 46 | 47 | # Common directory purposes 48 | DIRECTORY_PURPOSES = { 49 | 'auth': 'Authentication and authorization logic', 50 | 'models': 'Data models and database schemas', 51 | 'views': 'UI views and templates', 52 | 'controllers': 'Request handlers and business logic', 53 | 'services': 'Business logic and external service integrations', 54 | 'utils': 'Shared utility functions and helpers', 55 | 'helpers': 'Helper functions and utilities', 56 | 'tests': 'Test files and test utilities', 57 | 'test': 'Test files and test utilities', 58 | 'spec': 'Test specifications', 59 | 'docs': 'Project documentation', 60 | 'api': 'API endpoints and route handlers', 61 | 'components': 'Reusable UI components', 62 | 'lib': 'Library code and shared modules', 63 | 'src': 'Source code root directory', 64 | 'static': 'Static assets (images, CSS, etc.)', 65 | 'public': 'Publicly accessible files', 66 | 'config': 'Configuration files and settings', 67 | 'scripts': 'Build and utility scripts', 68 | 'middleware': 'Middleware functions and handlers', 69 | 'migrations': 'Database migration files', 70 | 'fixtures': 'Test fixtures and sample data' 71 | } 72 | 73 | 74 | def extract_function_calls_python(body: str, all_functions: Set[str]) -> List[str]: 75 | """Extract function calls from Python code body.""" 76 | calls = set() 77 | 78 | # Pattern for function calls: word followed by ( 79 | # Excludes: control flow keywords, built-ins we don't care about 80 | call_pattern = r'\b(\w+)\s*\(' 81 | exclude_keywords = { 82 | 'if', 'elif', 'while', 'for', 'with', 'except', 'def', 'class', 83 | 'return', 'yield', 'raise', 'assert', 'print', 'len', 'str', 84 | 'int', 'float', 'bool', 'list', 'dict', 'set', 'tuple', 'type', 85 | 'isinstance', 'issubclass', 'super', 'range', 'enumerate', 'zip', 86 | 'map', 'filter', 'sorted', 'reversed', 'open', 'input', 'eval' 87 | } 88 | 89 | for match in re.finditer(call_pattern, body): 90 | func_name = match.group(1) 91 | if func_name in all_functions and func_name not in exclude_keywords: 92 | calls.add(func_name) 93 | 94 | # Also catch method calls like self.method() or obj.method() 95 | method_pattern = r'(?:self|cls|\w+)\.(\w+)\s*\(' 96 | for match in re.finditer(method_pattern, body): 97 | method_name = match.group(1) 98 | if method_name in all_functions: 99 | calls.add(method_name) 100 | 101 | return sorted(list(calls)) 102 | 103 | 104 | def extract_function_calls_javascript(body: str, all_functions: Set[str]) -> List[str]: 105 | """Extract function calls from JavaScript/TypeScript code body.""" 106 | calls = set() 107 | 108 | # Pattern for function calls 109 | call_pattern = r'\b(\w+)\s*\(' 110 | exclude_keywords = { 111 | 'if', 'while', 'for', 'switch', 'catch', 'function', 'class', 112 | 'return', 'throw', 'new', 'typeof', 'instanceof', 'void', 113 | 'console', 'Array', 'Object', 'String', 'Number', 'Boolean', 114 | 'Promise', 'Math', 'Date', 'JSON', 'parseInt', 'parseFloat' 115 | } 116 | 117 | for match in re.finditer(call_pattern, body): 118 | func_name = match.group(1) 119 | if func_name in all_functions and func_name not in exclude_keywords: 120 | calls.add(func_name) 121 | 122 | # Method calls: obj.method() or this.method() 123 | method_pattern = r'(?:this|\w+)\.(\w+)\s*\(' 124 | for match in re.finditer(method_pattern, body): 125 | method_name = match.group(1) 126 | if method_name in all_functions: 127 | calls.add(method_name) 128 | 129 | return sorted(list(calls)) 130 | 131 | 132 | def build_call_graph(functions: Dict, classes: Dict) -> Tuple[Dict, Dict]: 133 | """Build bidirectional call graph from extracted functions and methods.""" 134 | calls_map = {} 135 | called_by_map = {} 136 | 137 | # Build calls_map from functions 138 | for func_name, func_info in functions.items(): 139 | if isinstance(func_info, dict) and 'calls' in func_info: 140 | calls_map[func_name] = func_info['calls'] 141 | 142 | # Build calls_map from class methods 143 | for class_name, class_info in classes.items(): 144 | if isinstance(class_info, dict) and 'methods' in class_info: 145 | for method_name, method_info in class_info['methods'].items(): 146 | if isinstance(method_info, dict) and 'calls' in method_info: 147 | full_method_name = f"{class_name}.{method_name}" 148 | calls_map[full_method_name] = method_info['calls'] 149 | 150 | # Build the reverse index (called_by_map) 151 | for func_name, called_funcs in calls_map.items(): 152 | for called_func in called_funcs: 153 | if called_func not in called_by_map: 154 | called_by_map[called_func] = [] 155 | if func_name not in called_by_map[called_func]: 156 | called_by_map[called_func].append(func_name) 157 | 158 | return calls_map, called_by_map 159 | 160 | 161 | def extract_python_signatures(content: str) -> Dict[str, Dict]: 162 | """Extract Python function and class signatures with full details for all files.""" 163 | result = { 164 | 'imports': [], 165 | 'functions': {}, 166 | 'classes': {}, 167 | 'constants': {}, 168 | 'variables': [], 169 | 'type_aliases': {}, 170 | 'enums': {}, 171 | 'call_graph': {} # Track function calls for flow analysis 172 | } 173 | 174 | # Split into lines for line-by-line analysis 175 | lines = content.split('\n') 176 | 177 | # Track current class context 178 | current_class = None 179 | current_class_indent = -1 180 | class_stack = [] # For nested classes 181 | 182 | # First pass: collect all function and method names for call detection 183 | all_function_names = set() 184 | for line in lines: 185 | func_match = re.match(r'^(?:[ \t]*)(async\s+)?def\s+(\w+)\s*\(', line) 186 | if func_match: 187 | all_function_names.add(func_match.group(2)) 188 | 189 | # Patterns 190 | class_pattern = r'^([ \t]*)class\s+(\w+)(?:\s*\((.*?)\))?:' 191 | func_pattern = r'^([ \t]*)(async\s+)?def\s+(\w+)\s*\((.*?)\)(?:\s*->\s*([^:]+))?:' 192 | property_pattern = r'^([ \t]*)(\w+)\s*:\s*([^=\n]+)' 193 | # Module-level constants (UPPERCASE_NAME = value) 194 | module_const_pattern = r'^([A-Z_][A-Z0-9_]*)\s*=\s*(.+)$' 195 | # Module-level variables with type annotations 196 | module_var_pattern = r'^(\w+)\s*:\s*([^=]+)\s*=' 197 | # Class-level constants 198 | class_const_pattern = r'^([ \t]+)([A-Z_][A-Z0-9_]*)\s*=\s*(.+)$' 199 | # Import patterns 200 | import_pattern = r'^(?:from\s+([^\s]+)\s+)?import\s+(.+)$' 201 | # Type alias pattern 202 | type_alias_pattern = r'^(\w+)\s*=\s*(?:Union|Optional|List|Dict|Tuple|Set|Type|Callable|Literal|TypeVar|NewType|TypedDict|Protocol)\[.+\]$' 203 | # Decorator pattern 204 | decorator_pattern = r'^([ \t]*)@(\w+)(?:\(.*\))?$' 205 | # Docstring pattern (matches next line after function/class) 206 | docstring_pattern = r'^([ \t]*)(?:\'\'\'|""")(.+?)(?:\'\'\'|""")' 207 | 208 | # Dunder methods to skip (unless in critical files) 209 | skip_dunder = {'__repr__', '__str__', '__hash__', '__eq__', '__ne__', 210 | '__lt__', '__le__', '__gt__', '__ge__', '__bool__'} 211 | 212 | # First pass: Extract imports 213 | for line in lines: 214 | import_match = re.match(import_pattern, line.strip()) 215 | if import_match: 216 | module, items = import_match.groups() 217 | if module: 218 | # from X import Y style 219 | result['imports'].append(module) 220 | else: 221 | # import X style 222 | for item in items.split(','): 223 | item = item.strip().split(' as ')[0] # Remove aliases 224 | result['imports'].append(item) 225 | 226 | # Track decorators for next function/method 227 | pending_decorators = [] 228 | 229 | i = 0 230 | while i < len(lines): 231 | line = lines[i] 232 | 233 | # Skip comments and docstrings 234 | if line.strip().startswith('#') or line.strip().startswith('"""') or line.strip().startswith("'''"): 235 | i += 1 236 | continue 237 | 238 | # Check for decorators 239 | decorator_match = re.match(decorator_pattern, line) 240 | if decorator_match: 241 | _, decorator_name = decorator_match.groups() 242 | pending_decorators.append(decorator_name) 243 | i += 1 244 | continue 245 | 246 | # Check for module-level constants (before checking classes) 247 | if not current_class: # Only at module level 248 | # Check for type aliases first 249 | type_alias_match = re.match(type_alias_pattern, line) 250 | if type_alias_match: 251 | alias_name = type_alias_match.group(1) 252 | result['type_aliases'][alias_name] = line.split('=', 1)[1].strip() 253 | i += 1 254 | continue 255 | 256 | const_match = re.match(module_const_pattern, line) 257 | if const_match: 258 | const_name, const_value = const_match.groups() 259 | # Clean up the value (remove comments, strip quotes for readability) 260 | const_value = const_value.split('#')[0].strip() 261 | # Determine type from value 262 | if const_value.startswith(('{', '[')): 263 | const_type = 'collection' 264 | elif const_value.startswith(("'", '"')): 265 | const_type = 'str' 266 | elif const_value.replace('.', '').replace('-', '').isdigit(): 267 | const_type = 'number' 268 | else: 269 | const_type = 'value' 270 | result['constants'][const_name] = const_type 271 | i += 1 272 | continue 273 | 274 | # Check for module-level typed variables 275 | var_match = re.match(module_var_pattern, line) 276 | if var_match: 277 | var_name, var_type = var_match.groups() 278 | if var_name not in result['variables'] and not var_name.startswith('_'): 279 | result['variables'].append(var_name) 280 | i += 1 281 | continue 282 | 283 | # Check for class definition 284 | class_match = re.match(class_pattern, line) 285 | if class_match: 286 | indent, name, bases = class_match.groups() 287 | indent_level = len(indent) 288 | 289 | # Handle nested classes - pop from stack if dedented 290 | while class_stack and indent_level <= class_stack[-1][1]: 291 | class_stack.pop() 292 | 293 | # Only process top-level classes for the index 294 | if indent_level == 0: 295 | class_info = {'methods': {}, 'class_constants': {}} 296 | 297 | # Check for decorators on the class 298 | if pending_decorators: 299 | class_info['decorators'] = pending_decorators.copy() 300 | pending_decorators.clear() 301 | 302 | # Add inheritance info and check special types 303 | if bases: 304 | base_list = [b.strip() for b in bases.split(',') if b.strip()] 305 | if base_list: 306 | class_info['inherits'] = base_list 307 | 308 | # Check for special class types 309 | base_names_lower = [b.lower() for b in base_list] 310 | if 'enum' in base_names_lower or any('enum' in b for b in base_names_lower): 311 | class_info['type'] = 'enum' 312 | # We'll extract enum values later 313 | elif 'exception' in base_names_lower or 'error' in base_names_lower or any('exception' in b or 'error' in b for b in base_names_lower): 314 | class_info['type'] = 'exception' 315 | elif 'abc' in base_names_lower or 'protocol' in base_names_lower: 316 | class_info['abstract'] = True 317 | 318 | # Extract docstring 319 | if i + 1 < len(lines): 320 | next_line = lines[i + 1].strip() 321 | doc_match = re.match(docstring_pattern, lines[i + 1]) 322 | if doc_match: 323 | _, doc_content = doc_match.groups() 324 | class_info['doc'] = doc_content.strip() 325 | 326 | class_info['line'] = i + 1 # Store line number (1-based) 327 | result['classes'][name] = class_info 328 | current_class = name 329 | current_class_indent = indent_level 330 | 331 | # Add to stack 332 | class_stack.append((name, indent_level)) 333 | i += 1 334 | continue 335 | 336 | # Check if we've left the current class (dedented to module level) 337 | if current_class and line.strip() and len(line) - len(line.lstrip()) <= current_class_indent: 338 | # Check if it's not just a blank line or comment 339 | if not line.strip().startswith('#'): 340 | current_class = None 341 | current_class_indent = -1 342 | 343 | # Check for class-level constants or enum values 344 | if current_class: 345 | # For enums, capture all uppercase attributes as values 346 | if result['classes'][current_class].get('type') == 'enum': 347 | # Enum value pattern (NAME = value or just NAME) 348 | enum_val_pattern = r'^([ \t]+)([A-Z_][A-Z0-9_]*)\s*(?:=\s*(.+))?$' 349 | enum_match = re.match(enum_val_pattern, line) 350 | if enum_match: 351 | indent, enum_name, enum_value = enum_match.groups() 352 | if len(indent) > current_class_indent: 353 | if 'values' not in result['classes'][current_class]: 354 | result['classes'][current_class]['values'] = [] 355 | result['classes'][current_class]['values'].append(enum_name) 356 | i += 1 357 | continue 358 | 359 | class_const_match = re.match(class_const_pattern, line) 360 | if class_const_match: 361 | indent, const_name, const_value = class_const_match.groups() 362 | if len(indent) > current_class_indent: 363 | # Clean up the value 364 | const_value = const_value.split('#')[0].strip() 365 | # Determine type 366 | if const_value.startswith(('{', '[')): 367 | const_type = 'collection' 368 | elif const_value.startswith(("'", '"')): 369 | const_type = 'str' 370 | elif const_value.replace('.', '').replace('-', '').isdigit(): 371 | const_type = 'number' 372 | else: 373 | const_type = 'value' 374 | result['classes'][current_class]['class_constants'][const_name] = const_type 375 | i += 1 376 | continue 377 | 378 | # Check for function/method definition 379 | # First check if this line starts a function definition 380 | func_start_pattern = r'^([ \t]*)(async\s+)?def\s+(\w+)\s*\(' 381 | func_start_match = re.match(func_start_pattern, line) 382 | 383 | if func_start_match: 384 | indent, is_async, name = func_start_match.groups() 385 | indent_level = len(indent) 386 | 387 | # Collect the full signature across multiple lines 388 | full_sig = line.rstrip() 389 | j = i 390 | 391 | # Keep collecting lines until we find the colon that ends the signature 392 | while j < len(lines) and not re.search(r'\).*:', lines[j]): 393 | j += 1 394 | if j < len(lines): 395 | full_sig += ' ' + lines[j].strip() 396 | 397 | # Make sure we have a complete signature 398 | if j >= len(lines): 399 | i += 1 400 | continue 401 | 402 | # Now parse the complete signature 403 | complete_match = re.match(func_pattern, full_sig) 404 | if complete_match: 405 | indent, is_async, name, params, return_type = complete_match.groups() 406 | i = j # Skip to the last line we processed 407 | else: 408 | # Failed to parse, skip this function 409 | i += 1 410 | continue 411 | 412 | # Clean params 413 | params = re.sub(r'\s+', ' ', params).strip() 414 | 415 | # Skip certain dunder methods (except __init__) 416 | if name in skip_dunder and name != '__init__': 417 | i += 1 418 | continue 419 | 420 | # Build function/method info 421 | func_info = { 422 | 'line': i + 1 # Store line number (1-based) 423 | } 424 | 425 | # Build full signature 426 | signature = f"({params})" 427 | if return_type: 428 | signature += f" -> {return_type.strip()}" 429 | if is_async: 430 | signature = "async " + signature 431 | 432 | # Add decorators if any 433 | if pending_decorators: 434 | func_info['decorators'] = pending_decorators.copy() 435 | # Check for abstractmethod 436 | if 'abstractmethod' in pending_decorators: 437 | if current_class: 438 | result['classes'][current_class]['abstract'] = True 439 | pending_decorators.clear() 440 | 441 | # Extract docstring 442 | if i + 1 < len(lines): 443 | doc_match = re.match(docstring_pattern, lines[i + 1]) 444 | if doc_match: 445 | _, doc_content = doc_match.groups() 446 | func_info['doc'] = doc_content.strip() 447 | 448 | # Extract function body to find calls 449 | func_body_start = i + 1 450 | func_body_lines = [] 451 | func_indent = len(indent) if indent else 0 452 | 453 | # Skip past any docstring (but include it in body for now) 454 | body_idx = func_body_start 455 | 456 | # Collect function body - everything indented more than the def line 457 | while body_idx < len(lines): 458 | body_line = lines[body_idx] 459 | 460 | # Skip empty lines 461 | if not body_line.strip(): 462 | func_body_lines.append(body_line) 463 | body_idx += 1 464 | continue 465 | 466 | # Check indentation to see if we're still in the function 467 | line_indent = len(body_line) - len(body_line.lstrip()) 468 | 469 | # If we hit a line that's not indented more than the function def, we're done 470 | if line_indent <= func_indent and body_line.strip(): 471 | break 472 | 473 | func_body_lines.append(body_line) 474 | body_idx += 1 475 | 476 | # Extract calls from the body 477 | if func_body_lines: 478 | func_body = '\n'.join(func_body_lines) 479 | calls = extract_function_calls_python(func_body, all_function_names) 480 | if calls: 481 | func_info['calls'] = calls 482 | 483 | # Always store as dict to include line number 484 | func_info['signature'] = signature 485 | 486 | # Determine where to place this function 487 | if current_class and indent_level > current_class_indent: 488 | # It's a method of the current class 489 | result['classes'][current_class]['methods'][name] = func_info 490 | elif indent_level == 0: 491 | # It's a module-level function 492 | result['functions'][name] = func_info 493 | 494 | # Check for class properties 495 | if current_class: 496 | prop_match = re.match(property_pattern, line) 497 | if prop_match: 498 | indent, prop_name, prop_type = prop_match.groups() 499 | if len(indent) > current_class_indent and not prop_name.startswith('_'): 500 | if 'properties' not in result['classes'][current_class]: 501 | result['classes'][current_class]['properties'] = [] 502 | result['classes'][current_class]['properties'].append(prop_name) 503 | 504 | i += 1 505 | 506 | # Post-process - remove empty collections 507 | for class_name, class_info in result['classes'].items(): 508 | if 'properties' in class_info and not class_info['properties']: 509 | del class_info['properties'] 510 | if 'class_constants' in class_info and not class_info['class_constants']: 511 | del class_info['class_constants'] 512 | if 'decorators' in class_info and not class_info['decorators']: 513 | del class_info['decorators'] 514 | if 'values' in class_info and not class_info['values']: 515 | del class_info['values'] 516 | 517 | # Remove empty module-level collections 518 | if not result['constants']: 519 | del result['constants'] 520 | if not result['variables']: 521 | del result['variables'] 522 | if not result['type_aliases']: 523 | del result['type_aliases'] 524 | if not result['enums']: 525 | del result['enums'] 526 | if not result['imports']: 527 | del result['imports'] 528 | 529 | # Move enum classes to enums section 530 | enums_to_move = {} 531 | for class_name, class_info in list(result['classes'].items()): 532 | if class_info.get('type') == 'enum': 533 | enums_to_move[class_name] = { 534 | 'values': class_info.get('values', []), 535 | 'doc': class_info.get('doc', '') 536 | } 537 | del result['classes'][class_name] 538 | 539 | if enums_to_move: 540 | result['enums'] = enums_to_move 541 | 542 | return result 543 | 544 | 545 | def extract_javascript_signatures(content: str) -> Dict[str, any]: 546 | """Extract JavaScript/TypeScript function and class signatures with full details.""" 547 | result = { 548 | 'imports': [], 549 | 'functions': {}, 550 | 'classes': {}, 551 | 'constants': {}, 552 | 'variables': [], 553 | 'type_aliases': {}, 554 | 'interfaces': {}, 555 | 'enums': {}, 556 | 'call_graph': {} # Track function calls for flow analysis 557 | } 558 | 559 | # Helper to convert character position to line number 560 | def pos_to_line(pos: int) -> int: 561 | return content[:pos].count('\n') + 1 562 | 563 | # First pass: collect all function names for call detection 564 | all_function_names = set() 565 | # Regular functions 566 | for match in re.finditer(r'(?:async\s+)?function\s+(\w+)', content): 567 | all_function_names.add(match.group(1)) 568 | # Arrow functions and const functions 569 | for match in re.finditer(r'(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(', content): 570 | all_function_names.add(match.group(1)) 571 | # Method names 572 | for match in re.finditer(r'(\w+)\s*\([^)]*\)\s*{', content): 573 | all_function_names.add(match.group(1)) 574 | 575 | # Extract imports first 576 | # import X from 'Y', import {X} from 'Y', import * as X from 'Y' 577 | import_pattern = r'import\s+(?:([^{}\s]+)|{([^}]+)}|\*\s+as\s+(\w+))\s+from\s+[\'"]([^\'"]+)[\'"]' 578 | for match in re.finditer(import_pattern, content): 579 | default_import, named_imports, namespace_import, module = match.groups() 580 | if module: 581 | result['imports'].append(module) 582 | 583 | # require() style imports 584 | require_pattern = r'(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\([\'"]([^\'"]+)[\'"]\)' 585 | for match in re.finditer(require_pattern, content): 586 | result['imports'].append(match.group(1)) 587 | 588 | # Extract type aliases (TypeScript) - simpler approach with brace counting 589 | type_alias_pattern = r'(?:export\s+)?type\s+(\w+)\s*=\s*(.+?)(?:;[\s]*(?:(?:export\s+)?(?:type|const|let|var|function|class|interface|enum)\s+|\/\/|$))' 590 | 591 | for match in re.finditer(type_alias_pattern, content, re.MULTILINE | re.DOTALL): 592 | alias_name, alias_type = match.groups() 593 | # Clean up the type definition 594 | clean_type = ' '.join(alias_type.strip().split()) 595 | 596 | # If it starts with { but seems incomplete, try to capture the full object 597 | if clean_type.startswith('{') and clean_type.count('{') > clean_type.count('}'): 598 | # Find the position after the = sign 599 | start_pos = match.start(2) 600 | brace_count = 0 601 | end_pos = start_pos 602 | 603 | # Count braces to find the complete type 604 | for i, char in enumerate(content[start_pos:]): 605 | if char == '{': 606 | brace_count += 1 607 | elif char == '}': 608 | brace_count -= 1 609 | if brace_count == 0: 610 | end_pos = start_pos + i + 1 611 | break 612 | 613 | if end_pos > start_pos: 614 | complete_type = content[start_pos:end_pos].strip() 615 | clean_type = ' '.join(complete_type.split()) 616 | 617 | result['type_aliases'][alias_name] = clean_type 618 | 619 | # Extract interfaces (TypeScript) 620 | interface_pattern = r'(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+([^{]+))?\s*{' 621 | for match in re.finditer(interface_pattern, content): 622 | interface_name, extends = match.groups() 623 | interface_info = {} 624 | if extends: 625 | interface_info['extends'] = [e.strip() for e in extends.split(',')] 626 | # Extract first line of JSDoc if present 627 | jsdoc_match = re.search(r'/\*\*\s*\n?\s*\*?\s*([^@\n]+)', content[:match.start()]) 628 | if jsdoc_match: 629 | interface_info['doc'] = jsdoc_match.group(1).strip() 630 | result['interfaces'][interface_name] = interface_info 631 | 632 | # Extract enums (TypeScript) 633 | enum_pattern = r'(?:export\s+)?enum\s+(\w+)\s*{' 634 | enum_matches = list(re.finditer(enum_pattern, content)) 635 | for match in enum_matches: 636 | enum_name = match.group(1) 637 | # Find enum values 638 | start_pos = match.end() 639 | brace_count = 1 640 | end_pos = start_pos 641 | for i in range(start_pos, len(content)): 642 | if content[i] == '{': 643 | brace_count += 1 644 | elif content[i] == '}': 645 | brace_count -= 1 646 | if brace_count == 0: 647 | end_pos = i 648 | break 649 | 650 | enum_body = content[start_pos:end_pos] 651 | # Extract enum values 652 | value_pattern = r'(\w+)\s*(?:=\s*[^,\n]+)?' 653 | values = re.findall(value_pattern, enum_body) 654 | result['enums'][enum_name] = {'values': values} 655 | 656 | # Extract module-level constants and variables 657 | # const CONSTANT_NAME = value 658 | const_pattern = r'(?:export\s+)?const\s+([A-Z_][A-Z0-9_]*)\s*=\s*([^;]+)' 659 | for match in re.finditer(const_pattern, content): 660 | const_name, const_value = match.groups() 661 | const_value = const_value.strip() 662 | if const_value.startswith(('{', '[')): 663 | const_type = 'collection' 664 | elif const_value.startswith(("'", '"', '`')): 665 | const_type = 'str' 666 | elif const_value.replace('.', '').replace('-', '').isdigit(): 667 | const_type = 'number' 668 | else: 669 | const_type = 'value' 670 | result['constants'][const_name] = const_type 671 | 672 | # let/const variables (not uppercase) 673 | var_pattern = r'(?:export\s+)?(?:let|const)\s+([a-z]\w*)\s*(?::\s*\w+)?\s*=' 674 | for match in re.finditer(var_pattern, content): 675 | var_name = match.group(1) 676 | if var_name not in result['variables']: 677 | result['variables'].append(var_name) 678 | 679 | # Find all classes first with their boundaries 680 | class_pattern = r'(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?' 681 | class_positions = {} # {class_name: (start_pos, end_pos)} 682 | 683 | for match in re.finditer(class_pattern, content): 684 | class_name, extends = match.groups() 685 | start_pos = match.start() 686 | 687 | # Find the class body (between { and }) 688 | brace_count = 0 689 | in_class = False 690 | end_pos = start_pos 691 | 692 | for i in range(match.end(), len(content)): 693 | if content[i] == '{': 694 | if not in_class: 695 | in_class = True 696 | brace_count += 1 697 | elif content[i] == '}': 698 | brace_count -= 1 699 | if brace_count == 0 and in_class: 700 | end_pos = i 701 | break 702 | 703 | class_positions[class_name] = (start_pos, end_pos) 704 | 705 | # Initialize class info 706 | class_info = { 707 | 'line': pos_to_line(start_pos), 708 | 'methods': {}, 709 | 'static_constants': {} 710 | } 711 | if extends: 712 | class_info['extends'] = extends 713 | # Check for exception classes 714 | if extends.lower() in ['error', 'exception'] or 'error' in extends.lower(): 715 | class_info['type'] = 'exception' 716 | 717 | # Extract JSDoc comment 718 | jsdoc_match = re.search(r'/\*\*\s*\n?\s*\*?\s*([^@\n]+)', content[:start_pos]) 719 | if jsdoc_match: 720 | class_info['doc'] = jsdoc_match.group(1).strip() 721 | 722 | result['classes'][class_name] = class_info 723 | 724 | # Extract methods from classes 725 | method_patterns = [ 726 | # Regular methods: methodName(...) { or async methodName(...) { 727 | r'^\s*(async\s+)?(\w+)\s*\((.*?)\)\s*(?::\s*([^{]+))?\s*{', 728 | # Arrow function properties: methodName = (...) => { 729 | r'^\s*(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=]+))?\s*=>', 730 | # Constructor 731 | r'^\s*(constructor)\s*\(([^)]*)\)\s*{' 732 | ] 733 | 734 | for class_name, (start, end) in class_positions.items(): 735 | class_content = content[start:end] 736 | 737 | for pattern in method_patterns: 738 | for match in re.finditer(pattern, class_content, re.MULTILINE): 739 | # Extract method name and params based on pattern 740 | if 'constructor' in pattern: 741 | method_name = '__init__' # Convert to Python-style 742 | params = match.group(2) 743 | return_type = None 744 | elif '=' in pattern: 745 | method_name = match.group(1) 746 | params = match.group(2) 747 | return_type = match.group(3) 748 | else: 749 | is_async = match.group(1) 750 | method_name = match.group(2) 751 | params = match.group(3) 752 | return_type = match.group(4) 753 | 754 | # Skip getters/setters and keywords 755 | if method_name in ['get', 'set', 'if', 'for', 'while', 'switch', 'catch', 'try']: 756 | continue 757 | 758 | method_info = { 759 | 'line': pos_to_line(start + match.start()) 760 | } 761 | 762 | # Build full signature 763 | params = re.sub(r'\s+', ' ', params).strip() 764 | signature = f"({params})" 765 | if return_type: 766 | signature += f": {return_type.strip()}" 767 | if 'async' in str(match.group(0)): 768 | signature = "async " + signature 769 | 770 | # Try to extract method body for call analysis 771 | method_start = match.end() 772 | # Find the opening brace 773 | brace_pos = class_content.find('{', method_start) 774 | if brace_pos != -1 and brace_pos - method_start < 100: 775 | # Extract method body 776 | brace_count = 1 777 | body_start = brace_pos + 1 778 | body_end = body_start 779 | 780 | for i in range(body_start, min(len(class_content), body_start + 3000)): 781 | if class_content[i] == '{': 782 | brace_count += 1 783 | elif class_content[i] == '}': 784 | brace_count -= 1 785 | if brace_count == 0: 786 | body_end = i 787 | break 788 | 789 | if body_end > body_start: 790 | method_body = class_content[body_start:body_end] 791 | calls = extract_function_calls_javascript(method_body, all_function_names) 792 | if calls: 793 | method_info['calls'] = calls 794 | 795 | # Store method info 796 | if method_info: 797 | method_info['signature'] = signature 798 | result['classes'][class_name]['methods'][method_name] = method_info 799 | else: 800 | result['classes'][class_name]['methods'][method_name] = signature 801 | 802 | # Extract static constants in class 803 | static_const_pattern = r'static\s+([A-Z_][A-Z0-9_]*)\s*=\s*([^;]+)' 804 | for match in re.finditer(static_const_pattern, class_content): 805 | const_name, const_value = match.groups() 806 | const_value = const_value.strip() 807 | if const_value.startswith(('{', '[')): 808 | const_type = 'collection' 809 | elif const_value.startswith(("'", '"', '`')): 810 | const_type = 'str' 811 | elif const_value.replace('.', '').replace('-', '').isdigit(): 812 | const_type = 'number' 813 | else: 814 | const_type = 'value' 815 | result['classes'][class_name]['static_constants'][const_name] = const_type 816 | 817 | # Extract standalone functions (not inside classes) 818 | func_patterns = [ 819 | # Function declarations 820 | r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*(?:<[^>]+>)?\s*\(([^)]*)\)(?:\s*:\s*([^{]+))?', 821 | # Arrow functions assigned to const 822 | r'(?:export\s+)?const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=]+))?\s*=>' 823 | ] 824 | 825 | for pattern in func_patterns: 826 | for match in re.finditer(pattern, content): 827 | func_name = match.group(1) 828 | params = match.group(2) if match.lastindex >= 2 else '' 829 | return_type = match.group(3) if match.lastindex >= 3 else None 830 | 831 | # Check if this function is inside any class 832 | func_pos = match.start() 833 | inside_class = False 834 | for class_name, (start, end) in class_positions.items(): 835 | if start <= func_pos <= end: 836 | inside_class = True 837 | break 838 | 839 | if not inside_class: 840 | func_info = { 841 | 'line': pos_to_line(func_pos) 842 | } 843 | 844 | # Build full signature 845 | params = re.sub(r'\s+', ' ', params).strip() 846 | signature = f"({params})" 847 | if return_type: 848 | signature += f": {return_type.strip()}" 849 | if 'async' in match.group(0): 850 | signature = "async " + signature 851 | 852 | # Try to extract function body for call analysis 853 | func_start = match.end() 854 | # Find the opening brace 855 | brace_pos = content.find('{', func_start) 856 | if brace_pos != -1 and brace_pos - func_start < 100: # Reasonable distance 857 | # Extract function body 858 | brace_count = 1 859 | body_start = brace_pos + 1 860 | body_end = body_start 861 | 862 | for i in range(body_start, min(len(content), body_start + 5000)): # Limit scan 863 | if content[i] == '{': 864 | brace_count += 1 865 | elif content[i] == '}': 866 | brace_count -= 1 867 | if brace_count == 0: 868 | body_end = i 869 | break 870 | 871 | if body_end > body_start: 872 | func_body = content[body_start:body_end] 873 | calls = extract_function_calls_javascript(func_body, all_function_names) 874 | if calls: 875 | func_info['calls'] = calls 876 | 877 | # Store function info 878 | if func_info: 879 | func_info['signature'] = signature 880 | result['functions'][func_name] = func_info 881 | else: 882 | result['functions'][func_name] = signature 883 | 884 | # Clean up empty collections 885 | for class_name, class_info in result['classes'].items(): 886 | if 'static_constants' in class_info and not class_info['static_constants']: 887 | del class_info['static_constants'] 888 | 889 | # Remove empty module-level collections 890 | if not result['constants']: 891 | del result['constants'] 892 | if not result['variables']: 893 | del result['variables'] 894 | if not result['imports']: 895 | del result['imports'] 896 | if not result['type_aliases']: 897 | del result['type_aliases'] 898 | if not result['interfaces']: 899 | del result['interfaces'] 900 | if not result['enums']: 901 | del result['enums'] 902 | 903 | return result 904 | 905 | 906 | def extract_function_calls_shell(body: str, all_functions: Set[str]) -> List[str]: 907 | """Extract function calls from shell script body.""" 908 | calls = set() 909 | 910 | # In shell, functions are called just by name (no parentheses) 911 | # We need to be careful to avoid false positives 912 | for func_name in all_functions: 913 | # Look for function name at start of line or after common shell operators 914 | patterns = [ 915 | rf'^\s*{func_name}\b', # Start of line 916 | rf'[;&|]\s*{func_name}\b', # After operators 917 | rf'\$\({func_name}\b', # Command substitution 918 | rf'`{func_name}\b', # Backtick substitution 919 | ] 920 | for pattern in patterns: 921 | if re.search(pattern, body, re.MULTILINE): 922 | calls.add(func_name) 923 | break 924 | 925 | return sorted(list(calls)) 926 | 927 | 928 | def extract_shell_signatures(content: str) -> Dict[str, any]: 929 | """Extract shell script function signatures and structure.""" 930 | result = { 931 | 'functions': {}, 932 | 'variables': [], 933 | 'exports': {}, 934 | 'sources': [], 935 | 'call_graph': {} # Track function calls 936 | } 937 | 938 | lines = content.split('\n') 939 | 940 | # First pass: collect all function names 941 | all_function_names = set() 942 | for line in lines: 943 | # Style 1: function_name() { 944 | match1 = re.match(r'^(\w+)\s*\(\)\s*\{?', line) 945 | if match1: 946 | all_function_names.add(match1.group(1)) 947 | # Style 2: function function_name { 948 | match2 = re.match(r'^function\s+(\w+)\s*\{?', line) 949 | if match2: 950 | all_function_names.add(match2.group(1)) 951 | 952 | # Function patterns 953 | # Style 1: function_name() { ... } 954 | func_pattern1 = r'^(\w+)\s*\(\)\s*\{?' 955 | # Style 2: function function_name { ... } 956 | func_pattern2 = r'^function\s+(\w+)\s*\{?' 957 | 958 | # Variable patterns 959 | # Export pattern: export VAR=value 960 | export_pattern = r'^export\s+([A-Z_][A-Z0-9_]*)(=(.*))?' 961 | # Regular variable: VAR=value (uppercase) 962 | var_pattern = r'^([A-Z_][A-Z0-9_]*)=(.+)$' 963 | 964 | # Source patterns - handle quotes and command substitution 965 | source_patterns = [ 966 | r'^(?:source|\.)\s+([\'"])([^\'"]+)\1', # Quoted paths 967 | r'^(?:source|\.)\s+(\$\([^)]+\)[^\s]*)', # Command substitution like $(dirname "$0")/file 968 | r'^(?:source|\.)\s+([^\s]+)', # Unquoted paths 969 | ] 970 | 971 | # Track if we're in a function 972 | in_function = False 973 | current_function = None 974 | function_start_line = -1 975 | 976 | for i, line in enumerate(lines): 977 | stripped = line.strip() 978 | 979 | # Skip empty lines and pure comments 980 | if not stripped or stripped.startswith('#!'): 981 | continue 982 | 983 | # Check for function definition (style 1) 984 | match = re.match(func_pattern1, stripped) 985 | if match: 986 | func_name = match.group(1) 987 | # Extract documentation comment if present 988 | doc = None 989 | if i > 0 and lines[i-1].strip().startswith('#'): 990 | doc = lines[i-1].strip()[1:].strip() 991 | 992 | # Try to find parameters from the function body 993 | params = [] 994 | brace_count = 0 995 | in_func_body = False 996 | 997 | # Look for $1, $2, etc. usage in the function body only 998 | for j in range(i+1, min(i+20, len(lines))): 999 | line_content = lines[j].strip() 1000 | 1001 | # Track braces to know when we're in the function 1002 | if '{' in line_content: 1003 | brace_count += line_content.count('{') 1004 | in_func_body = True 1005 | if '}' in line_content: 1006 | brace_count -= line_content.count('}') 1007 | if brace_count <= 0: 1008 | break # End of function 1009 | 1010 | # Only look for parameters inside the function body 1011 | if in_func_body: 1012 | param_matches = re.findall(r'\$(\d+)', lines[j]) 1013 | for p in param_matches: 1014 | param_num = int(p) 1015 | if param_num > 0 and param_num not in params: 1016 | params.append(param_num) 1017 | 1018 | # Build signature 1019 | if params: 1020 | max_param = max(params) 1021 | param_list = ' '.join(f'$1' if j == 1 else f'${{{j}}}' for j in range(1, max_param + 1)) 1022 | signature = f"({param_list})" 1023 | else: 1024 | signature = "()" 1025 | 1026 | # Extract function body for call analysis 1027 | func_body_lines = [] 1028 | brace_count = 0 1029 | in_func_body = False 1030 | for j in range(i+1, len(lines)): 1031 | line_content = lines[j] 1032 | if '{' in line_content: 1033 | brace_count += line_content.count('{') 1034 | in_func_body = True 1035 | if in_func_body: 1036 | func_body_lines.append(line_content) 1037 | if '}' in line_content: 1038 | brace_count -= line_content.count('}') 1039 | if brace_count <= 0: 1040 | break 1041 | 1042 | func_info = {} 1043 | if func_body_lines: 1044 | func_body = '\n'.join(func_body_lines) 1045 | calls = extract_function_calls_shell(func_body, all_function_names) 1046 | if calls: 1047 | func_info['calls'] = calls 1048 | 1049 | if doc: 1050 | func_info['doc'] = doc 1051 | 1052 | if func_info: 1053 | func_info['signature'] = signature 1054 | result['functions'][func_name] = func_info 1055 | else: 1056 | result['functions'][func_name] = signature 1057 | continue 1058 | 1059 | # Check for function definition (style 2) 1060 | match = re.match(func_pattern2, stripped) 1061 | if match: 1062 | func_name = match.group(1) 1063 | # Extract documentation comment if present 1064 | doc = None 1065 | if i > 0 and lines[i-1].strip().startswith('#'): 1066 | doc = lines[i-1].strip()[1:].strip() 1067 | 1068 | # Try to find parameters from the function body 1069 | params = [] 1070 | brace_count = 0 1071 | in_func_body = False 1072 | 1073 | # Look for $1, $2, etc. usage in the function body only 1074 | for j in range(i+1, min(i+20, len(lines))): 1075 | line_content = lines[j].strip() 1076 | 1077 | # Track braces to know when we're in the function 1078 | if '{' in line_content: 1079 | brace_count += line_content.count('{') 1080 | in_func_body = True 1081 | if '}' in line_content: 1082 | brace_count -= line_content.count('}') 1083 | if brace_count <= 0: 1084 | break # End of function 1085 | 1086 | # Only look for parameters inside the function body 1087 | if in_func_body: 1088 | param_matches = re.findall(r'\$(\d+)', lines[j]) 1089 | for p in param_matches: 1090 | param_num = int(p) 1091 | if param_num > 0 and param_num not in params: 1092 | params.append(param_num) 1093 | 1094 | # Build signature 1095 | if params: 1096 | max_param = max(params) 1097 | param_list = ' '.join(f'$1' if j == 1 else f'${{{j}}}' for j in range(1, max_param + 1)) 1098 | signature = f"({param_list})" 1099 | else: 1100 | signature = "()" 1101 | 1102 | # Extract function body for call analysis 1103 | func_body_lines = [] 1104 | brace_count = 0 1105 | in_func_body = False 1106 | for j in range(i+1, len(lines)): 1107 | line_content = lines[j] 1108 | if '{' in line_content: 1109 | brace_count += line_content.count('{') 1110 | in_func_body = True 1111 | if in_func_body: 1112 | func_body_lines.append(line_content) 1113 | if '}' in line_content: 1114 | brace_count -= line_content.count('}') 1115 | if brace_count <= 0: 1116 | break 1117 | 1118 | func_info = {} 1119 | if func_body_lines: 1120 | func_body = '\n'.join(func_body_lines) 1121 | calls = extract_function_calls_shell(func_body, all_function_names) 1122 | if calls: 1123 | func_info['calls'] = calls 1124 | 1125 | if doc: 1126 | func_info['doc'] = doc 1127 | 1128 | if func_info: 1129 | func_info['signature'] = signature 1130 | result['functions'][func_name] = func_info 1131 | else: 1132 | result['functions'][func_name] = signature 1133 | continue 1134 | 1135 | # Check for exports 1136 | match = re.match(export_pattern, stripped) 1137 | if match: 1138 | var_name = match.group(1) 1139 | var_value = match.group(3) if match.group(3) else None 1140 | if var_value: 1141 | # Determine type 1142 | if var_value.startswith(("'", '"')): 1143 | var_type = 'str' 1144 | elif var_value.isdigit(): 1145 | var_type = 'number' 1146 | else: 1147 | var_type = 'value' 1148 | result['exports'][var_name] = var_type 1149 | continue 1150 | 1151 | # Check for regular variables (uppercase) 1152 | match = re.match(var_pattern, stripped) 1153 | if match: 1154 | var_name = match.group(1) 1155 | # Only track if not already in exports 1156 | if var_name not in result['exports'] and var_name not in result['variables']: 1157 | result['variables'].append(var_name) 1158 | continue 1159 | 1160 | # Check for source/dot includes 1161 | for source_pattern in source_patterns: 1162 | match = re.match(source_pattern, stripped) 1163 | if match: 1164 | # Extract the file path based on which pattern matched 1165 | if len(match.groups()) == 2: # Quoted pattern 1166 | sourced_file = match.group(2) 1167 | else: # Unquoted or command substitution 1168 | sourced_file = match.group(1) 1169 | 1170 | sourced_file = sourced_file.strip() 1171 | if sourced_file and sourced_file not in result['sources']: 1172 | result['sources'].append(sourced_file) 1173 | break # Found a match, no need to try other patterns 1174 | 1175 | # Clean up empty collections 1176 | if not result['variables']: 1177 | del result['variables'] 1178 | if not result['exports']: 1179 | del result['exports'] 1180 | if not result['sources']: 1181 | del result['sources'] 1182 | 1183 | return result 1184 | 1185 | 1186 | def extract_markdown_structure(file_path: Path) -> Dict[str, List[str]]: 1187 | """Extract headers and architectural hints from markdown files.""" 1188 | try: 1189 | content = file_path.read_text(encoding='utf-8', errors='ignore') 1190 | except: 1191 | return {'sections': [], 'architecture_hints': []} 1192 | 1193 | # Extract headers (up to level 3) 1194 | headers = re.findall(r'^#{1,3}\s+(.+)$', content[:5000], re.MULTILINE) # Only scan first 5KB 1195 | 1196 | # Look for architectural hints 1197 | arch_patterns = [ 1198 | r'(?:located?|found?|stored?)\s+in\s+`?([\w\-\./]+)`?', 1199 | r'`?([\w\-\./]+)`?\s+(?:contains?|houses?|holds?)', 1200 | r'(?:see|check|look)\s+(?:in\s+)?`?([\w\-\./]+)`?\s+for', 1201 | r'(?:file|module|component)\s+`?([\w\-\./]+)`?', 1202 | ] 1203 | 1204 | hints = set() 1205 | for pattern in arch_patterns: 1206 | matches = re.findall(pattern, content[:5000], re.IGNORECASE) 1207 | for match in matches: 1208 | if '/' in match and not match.startswith('http'): 1209 | hints.add(match) 1210 | 1211 | return { 1212 | 'sections': headers[:10], # Limit to prevent bloat 1213 | 'architecture_hints': list(hints)[:5] 1214 | } 1215 | 1216 | 1217 | def infer_file_purpose(file_path: Path) -> Optional[str]: 1218 | """Infer the purpose of a file from its name and location.""" 1219 | name = file_path.stem.lower() 1220 | 1221 | # Common file purposes 1222 | if name in ['index', 'main', 'app']: 1223 | return 'Application entry point' 1224 | elif 'test' in name or 'spec' in name: 1225 | return 'Test file' 1226 | elif 'config' in name or 'settings' in name: 1227 | return 'Configuration' 1228 | elif 'route' in name: 1229 | return 'Route definitions' 1230 | elif 'model' in name: 1231 | return 'Data model' 1232 | elif 'util' in name or 'helper' in name: 1233 | return 'Utility functions' 1234 | elif 'middleware' in name: 1235 | return 'Middleware' 1236 | 1237 | return None 1238 | 1239 | 1240 | def infer_directory_purpose(path: Path, files_within: List[str]) -> Optional[str]: 1241 | """Infer directory purpose from naming patterns and contents.""" 1242 | dir_name = path.name.lower() 1243 | 1244 | # Check exact matches first 1245 | if dir_name in DIRECTORY_PURPOSES: 1246 | return DIRECTORY_PURPOSES[dir_name] 1247 | 1248 | # Check if directory name contains key patterns 1249 | for pattern, purpose in DIRECTORY_PURPOSES.items(): 1250 | if pattern in dir_name: 1251 | return purpose 1252 | 1253 | # Infer from contents 1254 | if files_within: 1255 | # Check for test files 1256 | if any('test' in f.lower() or 'spec' in f.lower() for f in files_within): 1257 | return 'Test files and test utilities' 1258 | 1259 | # Check for specific file patterns 1260 | if any('model' in f.lower() for f in files_within): 1261 | return 'Data models and schemas' 1262 | elif any('route' in f.lower() or 'endpoint' in f.lower() for f in files_within): 1263 | return 'API routes and endpoints' 1264 | elif any('component' in f.lower() for f in files_within): 1265 | return 'UI components' 1266 | 1267 | return None 1268 | 1269 | 1270 | def get_language_name(extension: str) -> str: 1271 | """Get readable language name from extension.""" 1272 | if extension in PARSEABLE_LANGUAGES: 1273 | return PARSEABLE_LANGUAGES[extension] 1274 | return extension[1:] if extension else 'unknown' 1275 | 1276 | 1277 | # Global cache for gitignore patterns 1278 | _gitignore_cache = {} 1279 | 1280 | 1281 | def parse_gitignore(gitignore_path: Path) -> List[str]: 1282 | """Parse a .gitignore file and return list of patterns.""" 1283 | if not gitignore_path.exists(): 1284 | return [] 1285 | 1286 | patterns = [] 1287 | try: 1288 | with open(gitignore_path, 'r') as f: 1289 | for line in f: 1290 | line = line.strip() 1291 | # Skip empty lines and comments 1292 | if not line or line.startswith('#'): 1293 | continue 1294 | patterns.append(line) 1295 | except: 1296 | pass 1297 | 1298 | return patterns 1299 | 1300 | 1301 | def load_gitignore_patterns(root_path: Path) -> Set[str]: 1302 | """Load all gitignore patterns from project root and merge with defaults.""" 1303 | # Use cached patterns if available 1304 | cache_key = str(root_path) 1305 | if cache_key in _gitignore_cache: 1306 | return _gitignore_cache[cache_key] 1307 | 1308 | # Start with default ignore patterns 1309 | patterns = set(IGNORE_DIRS) 1310 | 1311 | # Add patterns from .gitignore in project root 1312 | gitignore_path = root_path / '.gitignore' 1313 | if gitignore_path.exists(): 1314 | for pattern in parse_gitignore(gitignore_path): 1315 | # Handle negations (!) later if needed 1316 | if not pattern.startswith('!'): 1317 | patterns.add(pattern) 1318 | 1319 | # Cache the patterns 1320 | _gitignore_cache[cache_key] = patterns 1321 | return patterns 1322 | 1323 | 1324 | def matches_gitignore_pattern(path: Path, patterns: Set[str], root_path: Path) -> bool: 1325 | """Check if a path matches any gitignore pattern.""" 1326 | # Get relative path from root 1327 | try: 1328 | rel_path = path.relative_to(root_path) 1329 | except ValueError: 1330 | # Path is not relative to root 1331 | return False 1332 | 1333 | # Convert to string for pattern matching 1334 | path_str = str(rel_path) 1335 | path_parts = rel_path.parts 1336 | 1337 | for pattern in patterns: 1338 | # Check if any parent directory matches the pattern 1339 | # Strip trailing slash for directory patterns 1340 | clean_pattern = pattern.rstrip('/') 1341 | for part in path_parts: 1342 | if part == clean_pattern or fnmatch.fnmatch(part, clean_pattern): 1343 | return True 1344 | 1345 | # Check full path patterns 1346 | if '/' in pattern: 1347 | # Pattern includes directory separator 1348 | if fnmatch.fnmatch(path_str, pattern): 1349 | return True 1350 | # Also check without leading slash 1351 | if pattern.startswith('/') and fnmatch.fnmatch(path_str, pattern[1:]): 1352 | return True 1353 | else: 1354 | # Pattern is just a filename/directory name 1355 | # Check if the filename matches 1356 | if fnmatch.fnmatch(path.name, pattern): 1357 | return True 1358 | # Check if it matches the full relative path 1359 | if fnmatch.fnmatch(path_str, pattern): 1360 | return True 1361 | # Check with wildcards 1362 | if fnmatch.fnmatch(path_str, f'**/{pattern}'): 1363 | return True 1364 | 1365 | return False 1366 | 1367 | 1368 | def should_index_file(path: Path, root_path: Path = None) -> bool: 1369 | """Check if we should index this file.""" 1370 | # Must be a code or markdown file 1371 | if not (path.suffix in CODE_EXTENSIONS or path.suffix in MARKDOWN_EXTENSIONS): 1372 | return False 1373 | 1374 | # Skip if in hardcoded ignored directory (for safety) 1375 | for part in path.parts: 1376 | if part in IGNORE_DIRS: 1377 | return False 1378 | 1379 | # If root_path provided, check gitignore patterns 1380 | if root_path: 1381 | patterns = load_gitignore_patterns(root_path) 1382 | if matches_gitignore_pattern(path, patterns, root_path): 1383 | return False 1384 | 1385 | return True 1386 | 1387 | 1388 | def get_git_files(root_path: Path) -> Optional[List[Path]]: 1389 | """Get list of files tracked by git (respects .gitignore). 1390 | Returns None if not a git repository or git command fails.""" 1391 | try: 1392 | import subprocess 1393 | 1394 | # Run git ls-files to get tracked and untracked files that aren't ignored 1395 | result = subprocess.run( 1396 | ['git', 'ls-files', '--cached', '--others', '--exclude-standard'], 1397 | cwd=str(root_path), 1398 | capture_output=True, 1399 | text=True, 1400 | timeout=10 1401 | ) 1402 | 1403 | if result.returncode == 0: 1404 | files = [] 1405 | for line in result.stdout.strip().split('\n'): 1406 | if line: 1407 | file_path = root_path / line 1408 | # Only include actual files (not directories) 1409 | if file_path.is_file(): 1410 | files.append(file_path) 1411 | return files 1412 | else: 1413 | return None 1414 | except (subprocess.TimeoutExpired, FileNotFoundError, Exception): 1415 | # Git not available or command failed 1416 | return None -------------------------------------------------------------------------------- /scripts/project_index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Project Index for Claude Code 4 | Provides spatial-architectural awareness to prevent code duplication and misplacement. 5 | 6 | Features: 7 | - Directory tree structure visualization 8 | - Markdown documentation mapping with section headers 9 | - Directory purpose inference 10 | - Full function and class signatures with type annotations 11 | - Multi-language support (parsed vs listed) 12 | 13 | Usage: python project_index.py 14 | Output: PROJECT_INDEX.json 15 | """ 16 | 17 | __version__ = "0.2.0-beta" 18 | 19 | import json 20 | import os 21 | import re 22 | from datetime import datetime 23 | from pathlib import Path 24 | from typing import Dict, List, Optional, Tuple 25 | 26 | # Import shared utilities 27 | from index_utils import ( 28 | IGNORE_DIRS, PARSEABLE_LANGUAGES, CODE_EXTENSIONS, MARKDOWN_EXTENSIONS, 29 | DIRECTORY_PURPOSES, extract_python_signatures, extract_javascript_signatures, 30 | extract_shell_signatures, extract_markdown_structure, infer_file_purpose, 31 | infer_directory_purpose, get_language_name, should_index_file 32 | ) 33 | 34 | # Limits to keep it fast and simple 35 | MAX_FILES = 10000 36 | MAX_INDEX_SIZE = 1024 * 1024 # 1MB 37 | MAX_TREE_DEPTH = 5 38 | 39 | 40 | def generate_tree_structure(root_path: Path, max_depth: int = MAX_TREE_DEPTH) -> List[str]: 41 | """Generate a compact ASCII tree representation of the directory structure.""" 42 | tree_lines = [] 43 | 44 | def should_include_dir(path: Path) -> bool: 45 | """Check if directory should be included in tree.""" 46 | return ( 47 | path.name not in IGNORE_DIRS and 48 | not path.name.startswith('.') and 49 | path.is_dir() 50 | ) 51 | 52 | def add_tree_level(path: Path, prefix: str = "", depth: int = 0): 53 | """Recursively build tree structure.""" 54 | if depth > max_depth: 55 | if any(should_include_dir(p) for p in path.iterdir() if p.is_dir()): 56 | tree_lines.append(prefix + "└── ...") 57 | return 58 | 59 | try: 60 | items = sorted(path.iterdir(), key=lambda x: (x.is_file(), x.name.lower())) 61 | except PermissionError: 62 | return 63 | 64 | # Filter items 65 | dirs = [item for item in items if should_include_dir(item)] 66 | 67 | # Important files to show in tree 68 | important_files = [ 69 | item for item in items 70 | if item.is_file() and ( 71 | item.name in ['README.md', 'package.json', 'requirements.txt', 72 | 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 73 | 'setup.py', 'pyproject.toml', 'Makefile'] 74 | ) 75 | ] 76 | 77 | all_items = dirs + important_files 78 | 79 | for i, item in enumerate(all_items): 80 | is_last = i == len(all_items) - 1 81 | current_prefix = "└── " if is_last else "├── " 82 | 83 | name = item.name 84 | if item.is_dir(): 85 | name += "/" 86 | # Add file count for directories 87 | try: 88 | file_count = sum(1 for f in item.rglob('*') if f.is_file() and f.suffix in CODE_EXTENSIONS) 89 | if file_count > 0: 90 | name += f" ({file_count} files)" 91 | except: 92 | pass 93 | 94 | tree_lines.append(prefix + current_prefix + name) 95 | 96 | if item.is_dir(): 97 | next_prefix = prefix + (" " if is_last else "│ ") 98 | add_tree_level(item, next_prefix, depth + 1) 99 | 100 | # Start with root 101 | tree_lines.append(".") 102 | add_tree_level(root_path, "") 103 | return tree_lines 104 | 105 | 106 | # These functions are now imported from index_utils 107 | 108 | 109 | def build_index(root_dir: str) -> Tuple[Dict, int]: 110 | """Build the enhanced index with architectural awareness.""" 111 | root = Path(root_dir) 112 | index = { 113 | 'indexed_at': datetime.now().isoformat(), 114 | 'root': str(root), 115 | 'project_structure': { 116 | 'type': 'tree', 117 | 'root': '.', 118 | 'tree': [] 119 | }, 120 | 'documentation_map': {}, 121 | 'directory_purposes': {}, 122 | 'stats': { 123 | 'total_files': 0, 124 | 'total_directories': 0, 125 | 'fully_parsed': {}, 126 | 'listed_only': {}, 127 | 'markdown_files': 0 128 | }, 129 | 'files': {}, 130 | 'dependency_graph': {} 131 | } 132 | 133 | # Generate directory tree 134 | print("📊 Building directory tree...") 135 | index['project_structure']['tree'] = generate_tree_structure(root) 136 | 137 | file_count = 0 138 | dir_count = 0 139 | skipped_count = 0 140 | directory_files = {} # Track files per directory 141 | 142 | # Try to use git ls-files for better performance and accuracy 143 | print("🔍 Indexing files...") 144 | from index_utils import get_git_files 145 | git_files = get_git_files(root) 146 | 147 | if git_files is not None: 148 | # Use git-based file discovery 149 | print(f" Using git ls-files (found {len(git_files)} files)") 150 | files_to_process = git_files 151 | 152 | # Count directories from git files 153 | seen_dirs = set() 154 | for file_path in git_files: 155 | for parent in file_path.parents: 156 | if parent != root and parent not in seen_dirs: 157 | seen_dirs.add(parent) 158 | if parent not in directory_files: 159 | directory_files[parent] = [] 160 | dir_count = len(seen_dirs) 161 | else: 162 | # Fallback to manual file discovery 163 | print(" Using manual file discovery (git not available)") 164 | files_to_process = [] 165 | for file_path in root.rglob('*'): 166 | if file_path.is_dir(): 167 | # Track directories 168 | if not any(part in IGNORE_DIRS for part in file_path.parts): 169 | dir_count += 1 170 | directory_files[file_path] = [] 171 | continue 172 | 173 | if file_path.is_file(): 174 | files_to_process.append(file_path) 175 | 176 | # Process files 177 | for file_path in files_to_process: 178 | if file_count >= MAX_FILES: 179 | print(f"⚠️ Stopping at {MAX_FILES} files (project too large)") 180 | print(f" Consider adding more patterns to .gitignore to reduce scope") 181 | print(f" Or ask Claude to modify MAX_FILES in scripts/project_index.py") 182 | break 183 | 184 | if not should_index_file(file_path, root): 185 | skipped_count += 1 186 | continue 187 | 188 | # Track files in their directories 189 | parent_dir = file_path.parent 190 | if parent_dir in directory_files: 191 | directory_files[parent_dir].append(file_path.name) 192 | 193 | # Get relative path and language 194 | rel_path = file_path.relative_to(root) 195 | 196 | # Handle markdown files specially 197 | if file_path.suffix in MARKDOWN_EXTENSIONS: 198 | doc_structure = extract_markdown_structure(file_path) 199 | if doc_structure['sections'] or doc_structure['architecture_hints']: 200 | index['documentation_map'][str(rel_path)] = doc_structure 201 | index['stats']['markdown_files'] += 1 202 | continue 203 | 204 | # Handle code files 205 | language = get_language_name(file_path.suffix) 206 | 207 | # Base info for all files 208 | file_info = { 209 | 'language': language, 210 | 'parsed': False 211 | } 212 | 213 | # Add file purpose if we can infer it 214 | file_purpose = infer_file_purpose(file_path) 215 | if file_purpose: 216 | file_info['purpose'] = file_purpose 217 | 218 | # Try to parse if we support this language 219 | if file_path.suffix in PARSEABLE_LANGUAGES: 220 | try: 221 | content = file_path.read_text(encoding='utf-8', errors='ignore') 222 | 223 | # Extract based on language 224 | if file_path.suffix == '.py': 225 | extracted = extract_python_signatures(content) 226 | elif file_path.suffix in {'.js', '.ts', '.jsx', '.tsx'}: 227 | extracted = extract_javascript_signatures(content) 228 | elif file_path.suffix in {'.sh', '.bash'}: 229 | extracted = extract_shell_signatures(content) 230 | else: 231 | extracted = {'functions': {}, 'classes': {}} 232 | 233 | # Only add if we found something 234 | if extracted['functions'] or extracted['classes']: 235 | file_info.update(extracted) 236 | file_info['parsed'] = True 237 | 238 | # Update stats 239 | lang_key = PARSEABLE_LANGUAGES[file_path.suffix] 240 | index['stats']['fully_parsed'][lang_key] = \ 241 | index['stats']['fully_parsed'].get(lang_key, 0) + 1 242 | 243 | except Exception as e: 244 | # Parse error - just list the file 245 | index['stats']['listed_only'][language] = \ 246 | index['stats']['listed_only'].get(language, 0) + 1 247 | else: 248 | # Language not supported for parsing 249 | index['stats']['listed_only'][language] = \ 250 | index['stats']['listed_only'].get(language, 0) + 1 251 | 252 | # Add to index 253 | index['files'][str(rel_path)] = file_info 254 | file_count += 1 255 | 256 | # Progress indicator every 100 files 257 | if file_count % 100 == 0: 258 | print(f" Indexed {file_count} files...") 259 | 260 | # Infer directory purposes 261 | print("🏗️ Analyzing directory purposes...") 262 | for dir_path, files in directory_files.items(): 263 | if files: # Only process directories with files 264 | purpose = infer_directory_purpose(dir_path, files) 265 | if purpose: 266 | rel_dir = str(dir_path.relative_to(root)) 267 | if rel_dir != '.': 268 | index['directory_purposes'][rel_dir] = purpose 269 | 270 | index['stats']['total_files'] = file_count 271 | index['stats']['total_directories'] = dir_count 272 | 273 | # Build dependency graph 274 | print("🔗 Building dependency graph...") 275 | dependency_graph = {} 276 | 277 | for file_path, file_info in index['files'].items(): 278 | if file_info.get('imports'): 279 | # Normalize imports to resolve relative paths 280 | file_dir = Path(file_path).parent 281 | dependencies = [] 282 | 283 | for imp in file_info['imports']: 284 | # Handle relative imports 285 | if imp.startswith('.'): 286 | # Resolve relative import 287 | if imp.startswith('./'): 288 | # Same directory 289 | resolved = str(file_dir / imp[2:]) 290 | elif imp.startswith('../'): 291 | # Parent directory 292 | parts = imp.split('/') 293 | up_levels = len([p for p in parts if p == '..']) 294 | target_dir = file_dir 295 | for _ in range(up_levels): 296 | target_dir = target_dir.parent 297 | remaining = '/'.join(p for p in parts if p != '..') 298 | resolved = str(target_dir / remaining) if remaining else str(target_dir) 299 | else: 300 | # Module import like from . import X 301 | resolved = str(file_dir) 302 | 303 | # Try to find actual file 304 | for ext in ['.py', '.js', '.ts', '.jsx', '.tsx', '']: 305 | potential_file = resolved + ext 306 | if potential_file in index['files'] or potential_file.replace('\\', '/') in index['files']: 307 | dependencies.append(potential_file.replace('\\', '/')) 308 | break 309 | else: 310 | # External dependency or absolute import 311 | dependencies.append(imp) 312 | 313 | if dependencies: 314 | dependency_graph[file_path] = dependencies 315 | 316 | # Only add if not empty 317 | if dependency_graph: 318 | index['dependency_graph'] = dependency_graph 319 | 320 | # Build bidirectional call graph 321 | print("📞 Building call graph...") 322 | call_graph = {} 323 | called_by_graph = {} 324 | 325 | # Process all files to build call relationships 326 | for file_path, file_info in index['files'].items(): 327 | if not isinstance(file_info, dict): 328 | continue 329 | 330 | # Process functions in this file 331 | if 'functions' in file_info: 332 | for func_name, func_data in file_info['functions'].items(): 333 | if isinstance(func_data, dict) and 'calls' in func_data: 334 | # Track what this function calls 335 | full_func_name = f"{file_path}:{func_name}" 336 | call_graph[full_func_name] = func_data['calls'] 337 | 338 | # Build reverse index (called_by) 339 | for called in func_data['calls']: 340 | if called not in called_by_graph: 341 | called_by_graph[called] = [] 342 | called_by_graph[called].append(func_name) 343 | 344 | # Process methods in classes 345 | if 'classes' in file_info: 346 | for class_name, class_data in file_info['classes'].items(): 347 | if isinstance(class_data, dict) and 'methods' in class_data: 348 | for method_name, method_data in class_data['methods'].items(): 349 | if isinstance(method_data, dict) and 'calls' in method_data: 350 | # Track what this method calls 351 | full_method_name = f"{file_path}:{class_name}.{method_name}" 352 | call_graph[full_method_name] = method_data['calls'] 353 | 354 | # Build reverse index 355 | for called in method_data['calls']: 356 | if called not in called_by_graph: 357 | called_by_graph[called] = [] 358 | called_by_graph[called].append(f"{class_name}.{method_name}") 359 | 360 | # Add called_by information back to functions 361 | for file_path, file_info in index['files'].items(): 362 | if not isinstance(file_info, dict): 363 | continue 364 | 365 | if 'functions' in file_info: 366 | for func_name, func_data in file_info['functions'].items(): 367 | if func_name in called_by_graph: 368 | if isinstance(func_data, dict): 369 | func_data['called_by'] = called_by_graph[func_name] 370 | else: 371 | # Convert string signature to dict 372 | index['files'][file_path]['functions'][func_name] = { 373 | 'signature': func_data, 374 | 'called_by': called_by_graph[func_name] 375 | } 376 | 377 | if 'classes' in file_info: 378 | for class_name, class_data in file_info['classes'].items(): 379 | if isinstance(class_data, dict) and 'methods' in class_data: 380 | for method_name, method_data in class_data['methods'].items(): 381 | full_name = f"{class_name}.{method_name}" 382 | if method_name in called_by_graph or full_name in called_by_graph: 383 | callers = called_by_graph.get(method_name, []) + called_by_graph.get(full_name, []) 384 | if callers: 385 | if isinstance(method_data, dict): 386 | method_data['called_by'] = list(set(callers)) 387 | else: 388 | # Convert string signature to dict 389 | class_data['methods'][method_name] = { 390 | 'signature': method_data, 391 | 'called_by': list(set(callers)) 392 | } 393 | 394 | # Add staleness check 395 | week_old = datetime.now().timestamp() - 7 * 24 * 60 * 60 396 | index['staleness_check'] = week_old 397 | 398 | return index, skipped_count 399 | 400 | 401 | # infer_file_purpose is now imported from index_utils 402 | 403 | 404 | def convert_to_enhanced_dense_format(index: Dict) -> Dict: 405 | """Convert to enhanced dense format that preserves all AI-relevant information.""" 406 | dense = { 407 | 'at': index.get('indexed_at', ''), 408 | 'root': index.get('root', '.'), 409 | 'tree': index.get('project_structure', {}).get('tree', [])[:20], # Compact tree 410 | 'stats': index.get('stats', {}), 411 | 'f': {}, # Files 412 | 'g': [], # Call graph edges 413 | 'd': {}, # Documentation map 414 | 'deps': index.get('dependency_graph', {}), # Keep dependencies 415 | } 416 | 417 | def truncate_doc(doc: str, max_len: int = 80) -> str: 418 | """Truncate docstring to max length.""" 419 | if not doc: 420 | return '' 421 | doc = doc.strip().replace('\n', ' ') 422 | if len(doc) > max_len: 423 | return doc[:max_len-3] + '...' 424 | return doc 425 | 426 | # Build compressed files section 427 | for path, info in index.get('files', {}).items(): 428 | if not info.get('parsed', False): 429 | continue 430 | 431 | # Use abbreviated path 432 | abbrev_path = path.replace('scripts/', 's/').replace('src/', 'sr/').replace('tests/', 't/') 433 | 434 | file_entry = [] 435 | 436 | # Add language as single letter 437 | lang = info.get('language', 'unknown') 438 | lang_map = {'python': 'p', 'javascript': 'j', 'typescript': 't', 'shell': 's', 'json': 'j'} 439 | file_entry.append(lang_map.get(lang, 'u')) 440 | 441 | # Compress functions with docstrings: name:line:signature:calls:docstring 442 | funcs = [] 443 | for fname, fdata in info.get('functions', {}).items(): 444 | if isinstance(fdata, dict): 445 | line = fdata.get('line', 0) 446 | sig = fdata.get('signature', '()') 447 | # Compress signature 448 | sig = sig.replace(' -> ', '>').replace(': ', ':') 449 | calls = ','.join(fdata.get('calls', [])) 450 | doc = truncate_doc(fdata.get('doc', '')) 451 | funcs.append(f"{fname}:{line}:{sig}:{calls}:{doc}") 452 | else: 453 | funcs.append(f"{fname}:0:{fdata}::") 454 | 455 | if funcs: 456 | file_entry.append(funcs) 457 | 458 | # Compress classes with methods and docstrings 459 | classes = {} 460 | for cname, cdata in info.get('classes', {}).items(): 461 | if isinstance(cdata, dict): 462 | class_line = str(cdata.get('line', 0)) 463 | methods = [] 464 | for mname, mdata in cdata.get('methods', {}).items(): 465 | if isinstance(mdata, dict): 466 | mline = mdata.get('line', 0) 467 | msig = mdata.get('signature', '()') 468 | msig = msig.replace(' -> ', '>').replace(': ', ':') 469 | mcalls = ','.join(mdata.get('calls', [])) 470 | mdoc = truncate_doc(mdata.get('doc', '')) 471 | methods.append(f"{mname}:{mline}:{msig}:{mcalls}:{mdoc}") 472 | else: 473 | methods.append(f"{mname}:0:{mdata}::") 474 | 475 | if methods or class_line != '0': 476 | classes[cname] = [class_line, methods] 477 | 478 | if classes: 479 | file_entry.append(classes) 480 | 481 | # Only add file if it has content 482 | if len(file_entry) > 1: 483 | dense['f'][abbrev_path] = file_entry 484 | 485 | # Build call graph edges (keep bidirectional info) 486 | edges = set() 487 | for path, info in index.get('files', {}).items(): 488 | if info.get('parsed', False): 489 | # Extract function calls 490 | for fname, fdata in info.get('functions', {}).items(): 491 | if isinstance(fdata, dict): 492 | for called in fdata.get('calls', []): 493 | edges.add((fname, called)) 494 | for caller in fdata.get('called_by', []): 495 | edges.add((caller, fname)) 496 | 497 | # Extract method calls 498 | for cname, cdata in info.get('classes', {}).items(): 499 | if isinstance(cdata, dict): 500 | for mname, mdata in cdata.get('methods', {}).items(): 501 | if isinstance(mdata, dict): 502 | full_name = f"{cname}.{mname}" 503 | for called in mdata.get('calls', []): 504 | edges.add((full_name, called)) 505 | for caller in mdata.get('called_by', []): 506 | edges.add((caller, full_name)) 507 | 508 | # Convert edges to list format 509 | dense['g'] = [[e[0], e[1]] for e in edges] 510 | 511 | # Add compressed documentation map 512 | for doc_path, doc_info in index.get('documentation_map', {}).items(): 513 | sections = doc_info.get('sections', []) 514 | if sections: 515 | # Keep first 10 sections for better context 516 | dense['d'][doc_path] = sections[:10] 517 | 518 | # Add directory purposes if present 519 | if 'directory_purposes' in index: 520 | dense['dir_purposes'] = index['directory_purposes'] 521 | 522 | # Add staleness check timestamp 523 | if 'staleness_check' in index: 524 | dense['staleness'] = index['staleness_check'] 525 | 526 | return dense 527 | 528 | 529 | def compress_if_needed(dense_index: Dict, target_size: int = MAX_INDEX_SIZE) -> Dict: 530 | """Compress dense index further if it exceeds size limit.""" 531 | index_json = json.dumps(dense_index, separators=(',', ':')) 532 | current_size = len(index_json) 533 | 534 | if current_size <= target_size: 535 | return dense_index 536 | 537 | print(f"⚠️ Index too large ({current_size} bytes), compressing to {target_size}...") 538 | 539 | # Add safeguards 540 | iteration = 0 541 | MAX_ITERATIONS = 10 542 | 543 | # Progressive compression strategies 544 | 545 | # Step 1: Reduce tree to 10 items 546 | iteration += 1 547 | if iteration > MAX_ITERATIONS: 548 | print(f" ⚠️ Max compression iterations reached. Returning partially compressed index.") 549 | return dense_index 550 | 551 | print(f" Step {iteration}: Reducing tree structure...") 552 | if len(dense_index.get('tree', [])) > 10: 553 | dense_index['tree'] = dense_index['tree'][:10] 554 | dense_index['tree'].append("... (truncated)") 555 | current_size = len(json.dumps(dense_index, separators=(',', ':'))) 556 | if current_size <= target_size: 557 | print(f" ✅ Compressed to {current_size} bytes") 558 | return dense_index 559 | 560 | # Step 2: Truncate docstrings to 40 chars 561 | iteration += 1 562 | if iteration > MAX_ITERATIONS: 563 | print(f" ⚠️ Max compression iterations reached. Returning partially compressed index.") 564 | return dense_index 565 | 566 | print(f" Step {iteration}: Truncating docstrings...") 567 | for path, file_data in dense_index.get('f', {}).items(): 568 | if len(file_data) > 1 and isinstance(file_data[1], list): 569 | # Truncate function docstrings 570 | new_funcs = [] 571 | for func in file_data[1]: 572 | parts = func.split(':') 573 | if len(parts) >= 5 and len(parts[4]) > 40: 574 | parts[4] = parts[4][:37] + '...' 575 | new_funcs.append(':'.join(parts)) 576 | file_data[1] = new_funcs 577 | 578 | current_size = len(json.dumps(dense_index, separators=(',', ':'))) 579 | if current_size <= target_size: 580 | print(f" ✅ Compressed to {current_size} bytes") 581 | return dense_index 582 | 583 | # Step 3: Remove docstrings entirely 584 | iteration += 1 585 | if iteration > MAX_ITERATIONS: 586 | print(f" ⚠️ Max compression iterations reached. Returning partially compressed index.") 587 | return dense_index 588 | 589 | print(f" Step {iteration}: Removing docstrings entirely...") 590 | for path, file_data in dense_index.get('f', {}).items(): 591 | if len(file_data) > 1 and isinstance(file_data[1], list): 592 | # Remove docstrings from functions 593 | new_funcs = [] 594 | for func in file_data[1]: 595 | parts = func.split(':') 596 | if len(parts) >= 5: 597 | parts[4] = '' # Remove docstring 598 | new_funcs.append(':'.join(parts)) 599 | file_data[1] = new_funcs 600 | 601 | current_size = len(json.dumps(dense_index, separators=(',', ':'))) 602 | if current_size <= target_size: 603 | print(f" ✅ Compressed to {current_size} bytes") 604 | return dense_index 605 | 606 | # Step 4: Remove documentation map 607 | iteration += 1 608 | if iteration > MAX_ITERATIONS: 609 | print(f" ⚠️ Max compression iterations reached. Returning partially compressed index.") 610 | return dense_index 611 | 612 | print(f" Step {iteration}: Removing documentation map...") 613 | if 'd' in dense_index: 614 | del dense_index['d'] 615 | 616 | current_size = len(json.dumps(dense_index, separators=(',', ':'))) 617 | if current_size <= target_size: 618 | print(f" ✅ Compressed to {current_size} bytes") 619 | return dense_index 620 | 621 | # Step 5: Emergency truncation - keep most important files 622 | iteration += 1 623 | if iteration > MAX_ITERATIONS: 624 | print(f" ⚠️ Max compression iterations reached. Returning partially compressed index.") 625 | return dense_index 626 | 627 | print(f" Step {iteration}: Emergency truncation - keeping most important files...") 628 | if dense_index.get('f'): 629 | files_to_keep = int(len(dense_index['f']) * (target_size / current_size) * 0.9) 630 | if files_to_keep < 10: 631 | files_to_keep = 10 632 | 633 | # Calculate importance based on function count 634 | file_importance = {} 635 | for path, file_data in dense_index['f'].items(): 636 | importance = 0 637 | if len(file_data) > 1 and isinstance(file_data[1], list): 638 | importance = len(file_data[1]) # Number of functions 639 | if len(file_data) > 2: # Has classes 640 | importance += 5 641 | file_importance[path] = importance 642 | 643 | # Keep most important files 644 | sorted_files = sorted(file_importance.items(), key=lambda x: x[1], reverse=True) 645 | files_to_keep_set = set(path for path, _ in sorted_files[:files_to_keep]) 646 | 647 | # Remove less important files 648 | for path in list(dense_index['f'].keys()): 649 | if path not in files_to_keep_set: 650 | del dense_index['f'][path] 651 | 652 | print(f" Emergency truncation: kept {len(dense_index['f'])} most important files") 653 | 654 | final_size = len(json.dumps(dense_index, separators=(',', ':'))) 655 | print(f" Compressed from {len(index_json)} to {final_size} bytes") 656 | 657 | return dense_index 658 | 659 | 660 | def print_summary(index: Dict, skipped_count: int): 661 | """Print a helpful summary of what was indexed.""" 662 | stats = index['stats'] 663 | 664 | # Add warning if no files were found 665 | if stats['total_files'] == 0: 666 | print("\n⚠️ WARNING: No files were indexed!") 667 | print(" This might mean:") 668 | print(" • You're in the wrong directory") 669 | print(" • All files are being ignored (check .gitignore)") 670 | print(" • The project has no supported file types") 671 | print(f"\n Current directory: {os.getcwd()}") 672 | print(" Try running from your project root directory.") 673 | return 674 | 675 | print(f"\n📊 Project Analysis Complete:") 676 | print(f" 📁 {stats['total_directories']} directories indexed") 677 | print(f" 📄 {stats['total_files']} code files found") 678 | print(f" 📝 {stats['markdown_files']} documentation files analyzed") 679 | 680 | # Show fully parsed languages 681 | if stats['fully_parsed']: 682 | print("\n✅ Languages with full parsing:") 683 | for lang, count in sorted(stats['fully_parsed'].items()): 684 | print(f" • {count} {lang.capitalize()} files (with signatures)") 685 | 686 | # Show listed-only languages 687 | if stats['listed_only']: 688 | print("\n📋 Languages listed only:") 689 | for lang, count in sorted(stats['listed_only'].items()): 690 | print(f" • {count} {lang.capitalize()} files") 691 | 692 | # Show documentation insights 693 | if index.get('d'): 694 | print(f"\n📚 Documentation insights:") 695 | for doc_file, sections in list(index['d'].items())[:3]: 696 | print(f" • {doc_file}: {len(sections)} sections") 697 | 698 | # Show directory purposes 699 | if index.get('dir_purposes'): 700 | print(f"\n🏗️ Directory structure:") 701 | for dir_path, purpose in list(index['dir_purposes'].items())[:5]: 702 | print(f" • {dir_path}/: {purpose}") 703 | 704 | if skipped_count > 0: 705 | print(f"\n (Skipped {skipped_count} files in ignored directories)") 706 | 707 | 708 | def main(): 709 | """Run the enhanced indexer.""" 710 | print("🚀 Building Project Index...") 711 | 712 | # Check for target size from environment 713 | target_size_k = int(os.getenv('INDEX_TARGET_SIZE_K', '0')) 714 | if target_size_k > 0: 715 | # Convert k tokens to approximate bytes (1 token ≈ 4 chars) 716 | target_size_bytes = target_size_k * 1000 * 4 717 | print(f" Target size: {target_size_k}k tokens (~{target_size_bytes:,} bytes)") 718 | else: 719 | target_size_bytes = MAX_INDEX_SIZE 720 | 721 | print(" Analyzing project structure and documentation...") 722 | 723 | # Build index for current directory 724 | index, skipped_count = build_index('.') 725 | 726 | # Convert to enhanced dense format (always) 727 | index = convert_to_enhanced_dense_format(index) 728 | 729 | # Compress further if needed 730 | index = compress_if_needed(index, target_size_bytes) 731 | 732 | # Add metadata if requested via environment 733 | if target_size_k > 0: 734 | if '_meta' not in index: 735 | index['_meta'] = {} 736 | # Note: Full metadata is added by the hook after generation 737 | index['_meta']['target_size_k'] = target_size_k 738 | 739 | # Save to PROJECT_INDEX.json (minified) 740 | output_path = Path('PROJECT_INDEX.json') 741 | output_path.write_text(json.dumps(index, separators=(',', ':'))) 742 | 743 | # Print summary 744 | print_summary(index, skipped_count) 745 | 746 | print(f"\n💾 Saved to: {output_path}") 747 | 748 | # More concise output when called by hook 749 | if target_size_k > 0: 750 | actual_size = len(json.dumps(index, separators=(',', ':'))) 751 | actual_tokens = actual_size // 4 // 1000 752 | print(f"📊 Size: {actual_tokens}k tokens (target was {target_size_k}k)") 753 | else: 754 | print("\n✨ Claude now has architectural awareness of your project!") 755 | print(" • Knows WHERE to place new code") 756 | print(" • Understands project structure") 757 | print(" • Can navigate documentation") 758 | print("\n📌 Benefits:") 759 | print(" • Prevents code duplication") 760 | print(" • Ensures proper file placement") 761 | print(" • Maintains architectural consistency") 762 | 763 | 764 | if __name__ == '__main__': 765 | import sys 766 | if len(sys.argv) > 1 and sys.argv[1] == '--version': 767 | print(f"PROJECT_INDEX v{__version__}") 768 | sys.exit(0) 769 | main() -------------------------------------------------------------------------------- /scripts/run_python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Python runner that uses the saved Python command from installation 3 | 4 | INSTALL_DIR="$HOME/.claude-code-project-index" 5 | PYTHON_CMD_FILE="$INSTALL_DIR/.python_cmd" 6 | 7 | # Read the saved Python command, or fallback to common defaults 8 | if [[ -f "$PYTHON_CMD_FILE" ]]; then 9 | PYTHON_CMD=$(cat "$PYTHON_CMD_FILE") 10 | else 11 | # Fallback: try to find Python 12 | if command -v python3 &> /dev/null; then 13 | PYTHON_CMD="python3" 14 | elif command -v python &> /dev/null; then 15 | PYTHON_CMD="python" 16 | else 17 | echo "Error: Python not found. Please reinstall PROJECT_INDEX." >&2 18 | exit 1 19 | fi 20 | fi 21 | 22 | # Execute the Python script with all arguments 23 | exec "$PYTHON_CMD" "$@" -------------------------------------------------------------------------------- /scripts/stop_hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Stop hook for PROJECT_INDEX.json - Always regenerate if index exists. 4 | This ensures the index captures any changes made during the session. 5 | """ 6 | 7 | import json 8 | import sys 9 | import os 10 | import subprocess 11 | from pathlib import Path 12 | 13 | 14 | def main(): 15 | """Stop hook - regenerate index if PROJECT_INDEX.json exists.""" 16 | # Find PROJECT_INDEX.json by searching up the directory tree 17 | current_dir = Path.cwd() 18 | project_root = None 19 | 20 | check_dir = current_dir 21 | while check_dir != check_dir.parent: 22 | if (check_dir / 'PROJECT_INDEX.json').exists(): 23 | project_root = check_dir 24 | break 25 | check_dir = check_dir.parent 26 | 27 | # If no PROJECT_INDEX.json found, nothing to do 28 | if not project_root: 29 | return 30 | 31 | # Find the project_index.py script 32 | # First check if we're in the project itself 33 | local_script = project_root / 'scripts' / 'project_index.py' 34 | if local_script.exists(): 35 | script_path = local_script 36 | else: 37 | # Use the system-installed version 38 | script_path = Path.home() / '.claude-code-project-index' / 'scripts' / 'project_index.py' 39 | if not script_path.exists(): 40 | print("Warning: Could not find project_index.py", file=sys.stderr) 41 | return 42 | 43 | # Find Python command 44 | python_cmd_file = Path.home() / '.claude-code-project-index' / '.python_cmd' 45 | if python_cmd_file.exists(): 46 | python_cmd = python_cmd_file.read_text().strip() 47 | else: 48 | # Try common Python commands 49 | for cmd in ['python3', 'python', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8']: 50 | try: 51 | result = subprocess.run([cmd, '--version'], capture_output=True, text=True) 52 | if result.returncode == 0: 53 | python_cmd = cmd 54 | break 55 | except: 56 | continue 57 | else: 58 | print("Warning: Could not find Python", file=sys.stderr) 59 | return 60 | 61 | # Run the indexer silently 62 | try: 63 | os.chdir(project_root) 64 | result = subprocess.run( 65 | [python_cmd, str(script_path)], 66 | capture_output=True, 67 | text=True, 68 | timeout=10 69 | ) 70 | 71 | if result.returncode == 0: 72 | # Success - notify user that index was refreshed 73 | output = {"suppressOutput": False} 74 | print("🔄 PROJECT_INDEX.json refreshed with latest changes") 75 | sys.stdout.write(json.dumps(output) + '\n') 76 | else: 77 | # Failed but don't interrupt the user's workflow 78 | print(f"Warning: Failed to refresh index: {result.stderr}", file=sys.stderr) 79 | 80 | except subprocess.TimeoutExpired: 81 | print("Warning: Index refresh timed out", file=sys.stderr) 82 | except Exception as e: 83 | print(f"Warning: Index refresh error: {e}", file=sys.stderr) 84 | 85 | 86 | if __name__ == '__main__': 87 | main() -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | # Claude Code PROJECT_INDEX Uninstaller 5 | # Removes PROJECT_INDEX from ~/.claude-code-project-index 6 | 7 | echo "Claude Code PROJECT_INDEX Uninstaller" 8 | echo "======================================" 9 | echo "" 10 | 11 | INSTALL_DIR="$HOME/.claude-code-project-index" 12 | 13 | echo "This will remove:" 14 | echo " • PROJECT_INDEX hooks from ~/.claude/settings.json" 15 | echo " • Installation directory at $INSTALL_DIR" 16 | echo " • index-analyzer agent from ~/.claude/agents/" 17 | echo " • /index command if present" 18 | echo "" 19 | echo "⚠️ Note: This will NOT remove:" 20 | echo " • Any PROJECT_INDEX.json files in your projects" 21 | echo "" 22 | 23 | # Check if we're running interactively or via pipe 24 | if [ -t 0 ]; then 25 | # Interactive mode - can use read 26 | read -p "Continue with uninstall? (y/N): " -n 1 -r 27 | echo 28 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 29 | echo "Uninstall cancelled" 30 | exit 0 31 | fi 32 | else 33 | # Non-interactive mode (curl | bash) - skip confirmation 34 | echo "Running in non-interactive mode, proceeding with uninstall..." 35 | echo "" 36 | fi 37 | 38 | echo "" 39 | echo "Uninstalling PROJECT_INDEX..." 40 | 41 | # Remove /index command file 42 | if [[ -f "$HOME/.claude/commands/index.md" ]]; then 43 | echo "Removing /index command..." 44 | rm -f "$HOME/.claude/commands/index.md" 45 | echo "✓ /index command removed" 46 | fi 47 | 48 | # Remove hooks from settings.json 49 | SETTINGS_FILE="$HOME/.claude/settings.json" 50 | if [[ -f "$SETTINGS_FILE" ]]; then 51 | echo "Removing hooks from settings.json..." 52 | 53 | # Backup settings 54 | cp "$SETTINGS_FILE" "${SETTINGS_FILE}.uninstall-backup" 55 | 56 | # Remove PROJECT_INDEX hooks using jq 57 | jq ' 58 | # Remove PROJECT_INDEX UserPromptSubmit hooks 59 | if .hooks.UserPromptSubmit then 60 | .hooks.UserPromptSubmit = [.hooks.UserPromptSubmit[] | select( 61 | all(.hooks[]?.command // ""; 62 | contains("claude-code-project-index") | not) and 63 | all(.hooks[]?.command // ""; 64 | contains("i_flag_hook.py") | not) and 65 | all(.hooks[]?.command // ""; 66 | contains("project_index") | not) 67 | )] 68 | else . end | 69 | 70 | # Remove PROJECT_INDEX PostToolUse hooks 71 | if .hooks.PostToolUse then 72 | .hooks.PostToolUse = [.hooks.PostToolUse[] | select( 73 | all(.hooks[]?.command // ""; 74 | contains("claude-code-project-index") | not) and 75 | all(.hooks[]?.command // ""; 76 | contains("update_index.py") | not) and 77 | all(.hooks[]?.command // ""; 78 | contains("project_index") | not) 79 | )] 80 | else . end | 81 | 82 | # Remove PROJECT_INDEX Stop hooks 83 | if .hooks.Stop then 84 | .hooks.Stop = [.hooks.Stop[] | select( 85 | all(.hooks[]?.command // ""; 86 | contains("claude-code-project-index") | not) and 87 | all(.hooks[]?.command // ""; 88 | contains("stop_hook.py") | not) and 89 | all(.hooks[]?.command // ""; 90 | contains("reindex_if_needed.py") | not) and 91 | all(.hooks[]?.command // ""; 92 | contains("project_index") | not) 93 | )] 94 | else . end | 95 | 96 | # Clean up empty arrays 97 | if .hooks.UserPromptSubmit == [] then del(.hooks.UserPromptSubmit) else . end | 98 | if .hooks.PostToolUse == [] then del(.hooks.PostToolUse) else . end | 99 | if .hooks.Stop == [] then del(.hooks.Stop) else . end | 100 | 101 | # Clean up empty hooks object 102 | if .hooks == {} then del(.hooks) else . end 103 | ' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" 104 | 105 | echo "✓ Hooks removed" 106 | fi 107 | 108 | # Remove index-analyzer agent 109 | if [[ -f "$HOME/.claude/agents/index-analyzer.md" ]]; then 110 | echo "Removing index-analyzer agent..." 111 | rm -f "$HOME/.claude/agents/index-analyzer.md" 112 | echo "✓ Agent removed" 113 | fi 114 | 115 | # Remove installation directory 116 | if [[ -d "$INSTALL_DIR" ]]; then 117 | echo "Removing installation directory..." 118 | rm -rf "$INSTALL_DIR" 119 | echo "✓ Directory removed" 120 | fi 121 | 122 | echo "" 123 | echo "==========================================" 124 | echo "✅ PROJECT_INDEX uninstalled successfully!" 125 | echo "==========================================" 126 | echo "" 127 | echo "📝 Manual cleanup (if desired):" 128 | echo " Remove PROJECT_INDEX.json files from your projects:" 129 | echo " • Find them with: find ~ -name 'PROJECT_INDEX.json' -type f 2>/dev/null" 130 | echo "" 131 | echo "To reinstall, run:" 132 | echo " curl -fsSL https://raw.githubusercontent.com/ericbuess/claude-code-project-index/main/install.sh | bash" --------------------------------------------------------------------------------