├── .cursorignore ├── .cursorrules.example ├── .gitignore ├── README.md ├── config.yaml ├── config_generator.py ├── config_manual.yaml ├── main.py ├── requirements.txt ├── smart_analyzer.py └── tests ├── test_dir_structure.py ├── test_directory_finder.py └── test_walk_finder.py /.cursorignore: -------------------------------------------------------------------------------- 1 | tests 2 | .yaml 3 | __pycache__ 4 | .env 5 | .env.local 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | -------------------------------------------------------------------------------- /.cursorrules.example: -------------------------------------------------------------------------------- 1 | MOCK FILE -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | *.py,cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | cover/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | db.sqlite3-journal 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Rye 66 | .rye 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pdm 89 | .pdm.toml 90 | .pdm-python 91 | .pdm-build/ 92 | 93 | # PEP 582 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Added myself 104 | .python-version 105 | .curssorules 106 | venv 107 | 108 | # Environments 109 | .env 110 | .env*.local 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | # PyCharm 143 | .idea/ 144 | *.iml 145 | *.iws 146 | *.ipr 147 | .idea_modules/ 148 | 149 | # Next.js 150 | .next/ 151 | out/ 152 | next-env.d.ts 153 | 154 | # Node 155 | node_modules/ 156 | npm-debug.log* 157 | yarn-debug.log* 158 | yarn-error.log* 159 | .pnpm-debug.log* 160 | .npm 161 | .yarn 162 | .pnp.* 163 | 164 | # VSCode 165 | .vscode/ 166 | .settings/ 167 | *.code-workspace 168 | 169 | # macOS 170 | .DS_Store 171 | *.pem 172 | .AppleDouble 173 | .LSOverride 174 | Icon 175 | ._* 176 | 177 | # Vercel 178 | .vercel 179 | 180 | # TypeScript 181 | *.tsbuildinfo 182 | 183 | # Testing 184 | coverage/ 185 | .nyc_output/ 186 | 187 | # Debug 188 | debug.log 189 | npm-debug.log* 190 | 191 | # Misc 192 | .eslintcache 193 | .stylelintcache 194 | .env.local 195 | .env.*.local 196 | *.log 197 | logs/ 198 | .history/ 199 | 200 | # Local database files 201 | *.db 202 | *.sqlite 203 | *.sqlite3 204 | 205 | # Logs and temp files 206 | *.swp 207 | *.swo 208 | *~ 209 | tmp/ 210 | temp/ 211 | .tmp/ 212 | 213 | # Environment configuration 214 | .env.local 215 | .env.development.local 216 | .env.test.local 217 | .env.production.local 218 | 219 | # Build output 220 | dist/ 221 | build/ 222 | out/ 223 | public/dist/ 224 | 225 | # Cache directories 226 | .cache/ 227 | .npm/ 228 | .yarn/ 229 | .parcel-cache/ 230 | .rollup.cache/ 231 | .webpack/ 232 | 233 | # Editor directories and files 234 | *.sublime-project 235 | *.sublime-workspace 236 | *.komodoproject 237 | .komodotools/ 238 | .idea_modules/ 239 | .vs/ 240 | *.swp 241 | *.swo 242 | *.swn 243 | *.bak 244 | *~ 245 | 246 | 247 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 248 | 249 | # dependencies 250 | /node_modules 251 | /.pnp 252 | .pnp.js 253 | .yarn/install-state.gz 254 | 255 | # testing 256 | /coverage 257 | 258 | # next.js 259 | /.next/ 260 | /out/ 261 | 262 | # production 263 | /build 264 | 265 | # misc 266 | .DS_Store 267 | *.pem 268 | 269 | # debug 270 | npm-debug.log* 271 | yarn-debug.log* 272 | yarn-error.log* 273 | 274 | # local env files 275 | .env*.local 276 | 277 | # vercel 278 | .vercel 279 | 280 | # typescript 281 | *.tsbuildinfo 282 | next-env.d.ts 283 | 284 | # own scripts 285 | tree_*.txt 286 | docs.md 287 | tree_files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # agentic-cursorrules 4 | 5 | A Python-based tool for managing multiple AI agents in large codebases by enforcing strict file-tree partitioning, preventing conflicts, and maintaining coherence. Inspired by [cursor-boost](https://github.com/grp06/cursor-boost). 6 | 7 | ## Core Concept 8 | 9 | Agentic-cursorrules partitions your codebase into logical domains (frontend, backend, database, etc.) and generates domain-specific markdown files with explicit file-tree boundaries, ensuring AI agents operate within clearly defined contexts. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | git clone https://github.com/s-smits/agentic-cursorrules.git .agentic-cursorrules 15 | cd .agentic-cursorrules 16 | 17 | python -m venv venv 18 | source venv/bin/activate # or venv\Scripts\activate on Windows 19 | pip install -r requirements.txt 20 | 21 | cp .cursorrules.example ../.cursorrules 22 | ``` 23 | 24 | Ensure `.cursorrules` is in your working directory or project root. 25 | 26 | ## Usage 27 | 28 | ### 1. Manual Configuration 29 | 30 | Define domains explicitly in `config.yaml`: 31 | 32 | ```yaml 33 | project_title: "agentic-cursorrules" 34 | 35 | tree_focus: 36 | - "app" # Frontend logic 37 | - "api" # Backend services 38 | - "db" # Database layer 39 | - "api/auth/middleware" # Specific auth middleware 40 | - "app/components/forms" # Forms components 41 | ``` 42 | 43 | ### 2. Automatic Configuration 44 | 45 | Generate domains automatically: 46 | 47 | - **Filesystem scan** to auto-generate domains: 48 | ```bash 49 | python main.py --auto-config 50 | ``` 51 | 52 | - **Interactive tree structure input**: 53 | ```bash 54 | python main.py --tree-input 55 | ``` 56 | 57 | - **Reuse previously detected configuration** (`detected_config.yaml`): 58 | ```bash 59 | python main.py --use-detected 60 | ``` 61 | 62 | ### 3. Run the Generator 63 | 64 | ```bash 65 | python main.py [OPTIONS] 66 | ``` 67 | 68 | ### 4. Reference Generated Agent Files 69 | 70 | ```markdown 71 | @agent_app.md # Frontend agent 72 | @agent_api.md # Backend agent 73 | @agent_db.md # Database agent 74 | ``` 75 | 76 | ## Arguments 77 | 78 | | Option | Description | 79 | |------------------------|-------------------------------------------------------| 80 | | `--auto-config` | Auto-generate config domains from filesystem scan | 81 | | `--tree-input` | Interactively provide tree structure for config | 82 | | `--use-detected` | Use existing `detected_config.yaml` if available | 83 | | `--verify-config` | Print current `config.yaml` content | 84 | | `--local-agents` | Store agent files in script directory | 85 | | `--project-path PATH` | Specify target repository location | 86 | | `--project-title NAME` | Set project title for generated config | 87 | | `--recurring` | Run generator every 60 seconds | 88 | 89 | ## File Organization 90 | 91 | - Generated tree structures stored in `tree_files/` 92 | - Default: agent files placed directly in target repo 93 | - With `--local-agents`: agent files remain in agentic-cursorrules directory 94 | 95 | ## Advanced Features 96 | 97 | ### 🔍 Smart Directory Analysis 98 | 99 | - Multi-phase directory detection (standard → detailed scan → fallback) 100 | - Intelligent identification of significant code directories 101 | - Gitignore-aware file filtering 102 | 103 | ### 📂 Enhanced File Extension Detection 104 | 105 | - Comprehensive extension detection via GitHub repository data 106 | - Robust fallback extension list 107 | - Cached results for improved performance 108 | 109 | ### 📝 Agent File Generation 110 | 111 | - Context-aware markdown files for each domain 112 | - Intelligent naming conventions for nested directories 113 | - Clear directory descriptions and explicit boundaries 114 | 115 | ### ✅ Enhanced Path Handling 116 | 117 | - Absolute path resolution with `.resolve()` 118 | - Improved relative path calculations and graceful fallbacks 119 | - Detailed debug messages for easier troubleshooting 120 | 121 | ## Best Practices 122 | 123 | - Limit to 3-4 concurrent agents for optimal performance 124 | - Clearly define domain boundaries before development 125 | - Regularly review agent interactions at domain boundaries 126 | - Consider separate version control branches per domain 127 | 128 | ## IDE Compatibility 129 | 130 | Primarily designed for Cursor IDE, with experimental support for Windsurf IDE and planned support for other AI-enhanced editors. 131 | 132 | Use CMD/CTRL+Shift+P → ">Duplicate Workspace" to manage agents in separate workspace windows. 133 | 134 | ## Technical Overview 135 | 136 | ```yaml 137 | Key Features: 138 | - Domain-specific agent rulesets 139 | - Intelligent file-tree partitioning 140 | - Explicit boundary definitions 141 | - Optimized for multiple concurrent agents 142 | - YAML-based flexible configuration 143 | - Markdown-based instruction sets 144 | - Contextual file-tree awareness 145 | ``` 146 | 147 | ## Stars 148 | 149 | [![Star History Chart](https://api.star-history.com/svg?repos=s-smits/agentic-cursorrules&type=Date)](https://star-history.com/#s-smits/agentic-cursorrules&Date) 150 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | project_title: fastapi-next-starter 2 | tree_focus: 3 | - backend 4 | - frontend 5 | exclude_dirs: 6 | - .git 7 | - .next 8 | - __pycache__ 9 | - build 10 | - dist 11 | - env 12 | - node_modules 13 | - out 14 | - venv 15 | - coverage 16 | - .pytest_cache 17 | important_dirs: 18 | - api 19 | - components 20 | - pages 21 | - app 22 | - src 23 | - models 24 | - schemas 25 | - routers 26 | - hooks 27 | - features 28 | - public 29 | - shared 30 | - config 31 | - tests 32 | - interfaces 33 | include_extensions: 34 | - .py 35 | - .ts 36 | - .tsx 37 | - .js 38 | - .jsx 39 | - .json 40 | - .css 41 | - .scss 42 | - .html 43 | - .md 44 | - .prisma 45 | - .env 46 | -------------------------------------------------------------------------------- /config_generator.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import yaml 3 | import re 4 | import os 5 | from collections import defaultdict 6 | import sys 7 | 8 | class ConfigGenerator: 9 | """Dynamic config.yaml generator from file tree or filesystem.""" 10 | 11 | def __init__(self, project_dir=None, config_dir=None): 12 | """Initialize with project and config directories.""" 13 | self.project_dir = project_dir or Path.cwd().parent 14 | self.config_dir = config_dir or Path(__file__).parent 15 | self.config_path = self.config_dir / 'config.yaml' 16 | 17 | # Default configuration sections 18 | self.defaults = { 19 | 'important_dirs': [ 20 | 'components', 'pages', 'app', 'src', 'lib', 'utils', 'hooks', 21 | 'styles', 'public', 'assets', 'layouts', 'services', 'context', 'types' 22 | ], 23 | 'exclude_dirs': [ 24 | 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__', 25 | 'venv', 'env', '.git', 'coverage', 'tmp', 'temp', 'fonts', 'images', 'img' 26 | ], 27 | 'include_extensions': [ 28 | '.py', '.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.scss', 29 | '.html', '.md', '.vue', '.svelte' 30 | ] 31 | } 32 | 33 | print(f"ConfigGenerator initialized with:") 34 | print(f" - Project directory: {self.project_dir}") 35 | print(f" - Config path: {self.config_path}") 36 | 37 | def load_existing_config(self): 38 | """Load existing config.yaml if it exists.""" 39 | try: 40 | with open(self.config_path, 'r') as f: 41 | config = yaml.safe_load(f) or {} 42 | print(f"Loaded existing config with {len(config.get('tree_focus', []))} focus directories") 43 | return config 44 | except FileNotFoundError: 45 | print(f"No existing config found at {self.config_path}, creating new one") 46 | return {} 47 | 48 | def save_config(self, config): 49 | """Save config to config.yaml.""" 50 | try: 51 | print(f"Saving config to {self.config_path}") 52 | print(f"Config contains:") 53 | print(f" - {len(config.get('tree_focus', []))} focus directories") 54 | print(f" - {len(config.get('exclude_dirs', []))} excluded directories") 55 | 56 | # Ensure proper YAML formatting 57 | with open(self.config_path, 'w') as f: 58 | yaml.dump(config, f, default_flow_style=False, sort_keys=False) 59 | 60 | # Verify file was written 61 | if os.path.exists(self.config_path): 62 | file_size = os.path.getsize(self.config_path) 63 | print(f"✅ Config successfully written ({file_size} bytes)") 64 | with open(self.config_path, 'r') as f: 65 | # Print the first few lines to verify structure 66 | print("\nConfig preview:") 67 | for i, line in enumerate(f): 68 | if i < 10: # Print first 10 lines 69 | print(f" {line.rstrip()}") 70 | else: 71 | print(" ...") 72 | break 73 | else: 74 | print(f"❌ Failed to write config file - file doesn't exist after save") 75 | 76 | return config 77 | except Exception as e: 78 | print(f"❌ Error saving config: {str(e)}") 79 | raise 80 | 81 | def merge_with_defaults(self, config): 82 | """Merge config with default values for missing sections.""" 83 | result = config.copy() 84 | 85 | # Set project title if not present 86 | if 'project_title' not in result: 87 | result['project_title'] = self.project_dir.name 88 | print(f"Added project_title: {result['project_title']}") 89 | 90 | # Add default sections if missing 91 | for section, default_values in self.defaults.items(): 92 | if section not in result: 93 | result[section] = default_values 94 | print(f"Added default {section} ({len(default_values)} items)") 95 | 96 | return result 97 | 98 | def generate_from_tree_text(self, tree_text): 99 | """Generate config from a tree text representation.""" 100 | print("\nGenerating config from tree text...") 101 | 102 | # Parse the tree structure 103 | directories = self._parse_directories_from_tree(tree_text) 104 | print(f"Parsed {len(directories)} directories from tree text") 105 | 106 | # Generate focus dirs from the tree 107 | focus_dirs = self._identify_focus_dirs(directories) 108 | print(f"Identified {len(focus_dirs)} focus directories: {', '.join(focus_dirs)}") 109 | 110 | # Extract excluded dirs 111 | exclude_dirs = self._identify_exclude_dirs(directories) 112 | print(f"Identified {len(exclude_dirs)} exclude directories: {', '.join(exclude_dirs)}") 113 | 114 | # Load existing config 115 | config = self.load_existing_config() 116 | 117 | # Update with new values 118 | config['tree_focus'] = focus_dirs 119 | 120 | # Add excluded dirs without duplicates 121 | if 'exclude_dirs' in config: 122 | # Combine existing excludes with newly detected ones 123 | config['exclude_dirs'] = sorted(set(config['exclude_dirs']).union(exclude_dirs)) 124 | else: 125 | config['exclude_dirs'] = exclude_dirs 126 | 127 | # Merge with defaults and save 128 | config = self.merge_with_defaults(config) 129 | 130 | # Ensure tree_focus is at the top after project_title 131 | ordered_config = {} 132 | ordered_config['project_title'] = config.pop('project_title') 133 | ordered_config['tree_focus'] = config.pop('tree_focus') 134 | ordered_config.update(config) # Add remaining sections 135 | 136 | return self.save_config(ordered_config) 137 | 138 | def generate_from_filesystem(self): 139 | """Generate config by directly scanning the filesystem.""" 140 | print("\nGenerating config from filesystem...") 141 | focus_dirs = [] 142 | exclude_dirs = set(self.defaults['exclude_dirs']) 143 | 144 | # Scan first level directories 145 | for item in self.project_dir.iterdir(): 146 | if not item.is_dir(): 147 | continue 148 | 149 | # Skip common excluded directories 150 | if item.name in exclude_dirs or item.name.startswith('.'): 151 | continue 152 | 153 | # Check if this is a significant directory (contains code files) 154 | if self._is_significant_directory(item): 155 | focus_dirs.append(item.name) 156 | print(f"Found significant directory: {item.name}") 157 | 158 | print(f"Identified {len(focus_dirs)} focus directories: {', '.join(focus_dirs)}") 159 | 160 | # Load existing config 161 | config = self.load_existing_config() 162 | 163 | # Update with new values 164 | config['tree_focus'] = sorted(focus_dirs) 165 | config['exclude_dirs'] = sorted(exclude_dirs) 166 | 167 | # Merge with defaults and save 168 | config = self.merge_with_defaults(config) 169 | 170 | # Ensure tree_focus is at the top after project_title 171 | ordered_config = {} 172 | ordered_config['project_title'] = config.pop('project_title') 173 | ordered_config['tree_focus'] = config.pop('tree_focus') 174 | ordered_config.update(config) # Add remaining sections 175 | 176 | return self.save_config(ordered_config) 177 | 178 | def _is_significant_directory(self, directory): 179 | """Check if directory contains code files or important subdirectories.""" 180 | # Count code files 181 | code_files = 0 182 | for ext in self.defaults['include_extensions']: 183 | code_files += len(list(directory.glob(f"**/*{ext}"))) 184 | if code_files > 2: # If we find more than 2 code files, it's significant 185 | return True 186 | 187 | # Check for important subdirectories 188 | for subdir in directory.iterdir(): 189 | if subdir.is_dir() and subdir.name in self.defaults['important_dirs']: 190 | return True 191 | 192 | return False 193 | 194 | def _parse_directories_from_tree(self, tree_text): 195 | """Extract directory structure from tree text.""" 196 | directories = set() 197 | dir_pattern = re.compile(r'[│├└─\s]*([^/\n]+)/') 198 | 199 | for line in tree_text.split('\n'): 200 | # Match directory lines (ending with /) 201 | if '/' in line: 202 | match = dir_pattern.match(line) 203 | if match: 204 | dir_name = match.group(1).strip() 205 | if dir_name: 206 | directories.add(dir_name) 207 | 208 | return directories 209 | 210 | def _identify_focus_dirs(self, directories): 211 | """Identify key directories for tree_focus.""" 212 | # Prioritize important directories first 213 | focus_dirs = [d for d in directories if d in self.defaults['important_dirs']] 214 | 215 | # Add other potentially important directories 216 | common_focus_dirs = ['api', 'app', 'src', 'server', 'client', 'web', 'mobile', 'backend', 'frontend'] 217 | for dir_name in common_focus_dirs: 218 | if dir_name in directories and dir_name not in focus_dirs: 219 | focus_dirs.append(dir_name) 220 | 221 | # If we still don't have focus dirs, add all non-excluded directories 222 | if not focus_dirs: 223 | focus_dirs = [d for d in directories if d not in self.defaults['exclude_dirs']] 224 | 225 | return sorted(focus_dirs) 226 | 227 | def _identify_exclude_dirs(self, directories): 228 | """Identify directories that should be excluded.""" 229 | # Common directories to exclude 230 | exclude_dirs = set() 231 | 232 | for dir_name in directories: 233 | if dir_name.lower() in [d.lower() for d in self.defaults['exclude_dirs']]: 234 | exclude_dirs.add(dir_name) 235 | # Binary/media content directories 236 | elif dir_name.lower() in ['fonts', 'images', 'img', 'media', 'static', 'assets']: 237 | exclude_dirs.add(dir_name) 238 | 239 | return sorted(list(exclude_dirs)) -------------------------------------------------------------------------------- /config_manual.yaml: -------------------------------------------------------------------------------- 1 | project_title: fastapi-next-starter 2 | tree_focus: 3 | - backend 4 | - frontend 5 | exclude_dirs: 6 | - .git 7 | - .next 8 | - __pycache__ 9 | - build 10 | - dist 11 | - env 12 | - node_modules 13 | - out 14 | - venv 15 | - coverage 16 | - .pytest_cache 17 | important_dirs: 18 | - api 19 | - components 20 | - pages 21 | - app 22 | - src 23 | - models 24 | - schemas 25 | - routers 26 | - hooks 27 | - features 28 | - public 29 | - shared 30 | - config 31 | - tests 32 | - interfaces 33 | include_extensions: 34 | - .py 35 | - .ts 36 | - .tsx 37 | - .js 38 | - .jsx 39 | - .json 40 | - .css 41 | - .scss 42 | - .html 43 | - .md 44 | - .prisma 45 | - .env -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set 3 | import yaml 4 | from gitignore_parser import parse_gitignore 5 | import time 6 | import argparse 7 | import sys 8 | import shutil 9 | import os 10 | import re 11 | from collections import defaultdict, Counter 12 | from smart_analyzer import SmartCodeAnalyzer 13 | 14 | class ConfigUpdater: 15 | """Handles config.yaml generation and updates.""" 16 | 17 | def __init__(self, config_dir: Path): 18 | self.config_dir = config_dir 19 | self.config_path = config_dir / 'config.yaml' 20 | 21 | # Default configuration sections 22 | self.defaults = { 23 | 'important_dirs': [ 24 | 'components', 'pages', 'app', 'src', 'lib', 'utils', 'hooks', 25 | 'styles', 'public', 'assets', 'layouts', 'services', 'context', 'types' 26 | ], 27 | 'exclude_dirs': [ 28 | 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__', 29 | 'venv', 'env', '.git', 'coverage', 'tmp', 'temp' 30 | ], 31 | 'include_extensions': [ 32 | '.py', '.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.scss', 33 | '.html', '.md', '.vue', '.svelte' 34 | ] 35 | } 36 | 37 | def from_tree_text(self, tree_text, project_name="cursorrules-agentic"): 38 | """Generate config from tree text and save it.""" 39 | print("\nUpdating config.yaml from tree text...") 40 | 41 | # Parse directories from tree text 42 | directories = self._parse_directories(tree_text) 43 | print(f"Found {len(directories)} directories in tree text") 44 | 45 | # Identify focus directories and exclude directories 46 | focus_dirs = self._identify_focus_dirs(directories) 47 | exclude_dirs = self._identify_exclude_dirs(directories) 48 | 49 | # Create or update config 50 | config = self._create_config(project_name, focus_dirs, exclude_dirs) 51 | 52 | # Save and verify 53 | return self._save_config(config) 54 | 55 | def _parse_directories(self, tree_text): 56 | """Extract directories from tree text.""" 57 | directories = set() 58 | dir_pattern = re.compile(r'[│├└─\s]*([^/\n]+)/') 59 | 60 | for line in tree_text.split('\n'): 61 | if '/' in line: # Directory lines end with / 62 | match = dir_pattern.search(line) 63 | if match: 64 | dir_name = match.group(1).strip() 65 | if dir_name and not dir_name.startswith('.'): 66 | directories.add(dir_name) 67 | 68 | return directories 69 | 70 | def _identify_focus_dirs(self, directories): 71 | """Identify which directories should be in tree_focus.""" 72 | focus_dirs = [] 73 | 74 | # First add important directories 75 | important = set(self.defaults['important_dirs']) 76 | for dir_name in directories: 77 | if dir_name in important: 78 | focus_dirs.append(dir_name) 79 | 80 | # Then add common top-level directories 81 | common_top = ['api', 'app', 'src', 'backend', 'frontend', 'server', 'client'] 82 | for dir_name in common_top: 83 | if dir_name in directories and dir_name not in focus_dirs: 84 | focus_dirs.append(dir_name) 85 | 86 | # If still empty, add remaining non-excluded directories 87 | if not focus_dirs: 88 | exclude_set = set(self.defaults['exclude_dirs']) 89 | focus_dirs = [d for d in directories if d not in exclude_set] 90 | 91 | return sorted(focus_dirs) 92 | 93 | def _identify_exclude_dirs(self, directories): 94 | """Identify which directories should be excluded.""" 95 | exclude_dirs = [] 96 | standard_excludes = set(self.defaults['exclude_dirs']) 97 | 98 | for dir_name in directories: 99 | if dir_name.lower() in [d.lower() for d in standard_excludes]: 100 | exclude_dirs.append(dir_name) 101 | # Also add binary/media directories 102 | elif dir_name.lower() in ['fonts', 'images', 'img', 'media', 'static']: 103 | exclude_dirs.append(dir_name) 104 | 105 | return sorted(exclude_dirs) 106 | 107 | def _create_config(self, project_name, focus_dirs, exclude_dirs): 108 | """Create properly structured config dictionary.""" 109 | # Start with existing config if available 110 | try: 111 | with open(self.config_path, 'r') as f: 112 | config = yaml.safe_load(f) or {} 113 | except FileNotFoundError: 114 | config = {} 115 | 116 | # Create ordered config with proper structure 117 | ordered_config = {} 118 | 119 | # Project title always first 120 | ordered_config['project_title'] = project_name 121 | 122 | # Tree focus always second 123 | ordered_config['tree_focus'] = focus_dirs 124 | 125 | # Add exclude dirs (with existing ones if present) 126 | if 'exclude_dirs' in config: 127 | exclude_set = set(config['exclude_dirs']).union(exclude_dirs) 128 | ordered_config['exclude_dirs'] = sorted(exclude_set) 129 | else: 130 | ordered_config['exclude_dirs'] = exclude_dirs 131 | 132 | # Add remaining sections from defaults if not present 133 | for section, values in self.defaults.items(): 134 | if section != 'exclude_dirs' and section not in ordered_config: 135 | ordered_config[section] = config.get(section, values) 136 | 137 | return ordered_config 138 | 139 | def _save_config(self, config): 140 | """Save config to file and verify it was written.""" 141 | try: 142 | # Save with consistent formatting 143 | with open(self.config_path, 'w') as f: 144 | yaml.dump(config, f, default_flow_style=False, sort_keys=False) 145 | 146 | # Verify file was created 147 | if os.path.exists(self.config_path): 148 | file_size = os.path.getsize(self.config_path) 149 | print(f"✅ Config successfully written to {self.config_path} ({file_size} bytes)") 150 | print(f" - Added {len(config['tree_focus'])} focus directories") 151 | print(f" - Added {len(config['exclude_dirs'])} excluded directories") 152 | return True 153 | else: 154 | print(f"❌ Failed to create config file at {self.config_path}") 155 | return False 156 | except Exception as e: 157 | print(f"❌ Error saving config: {str(e)}") 158 | return False 159 | 160 | def _update_config(self, focus_dirs): 161 | """Update config.yaml with the identified focus directories.""" 162 | # Define the auto config path 163 | config_auto_path = self.config_dir / 'config_auto.yaml' 164 | 165 | # Create config 166 | config = {} 167 | config['project_title'] = self.project_dir.name 168 | config['tree_focus'] = focus_dirs 169 | 170 | # Add exclude dirs 171 | config['exclude_dirs'] = list(self.exclude_dirs) 172 | 173 | # Ensure ordered structure 174 | ordered_config = {} 175 | ordered_config['project_title'] = config.pop('project_title') 176 | ordered_config['tree_focus'] = config.pop('tree_focus') 177 | ordered_config.update(config) # Add remaining sections 178 | 179 | # Write the updated config 180 | with open(config_auto_path, 'w') as f: 181 | yaml.dump(ordered_config, f, default_flow_style=False, sort_keys=False) 182 | 183 | # Also update standard config.yaml as a backup 184 | with open(self.config_path, 'w') as f: 185 | yaml.dump(ordered_config, f, default_flow_style=False, sort_keys=False) 186 | 187 | print(f"\n✅ Updated config_auto.yaml with {len(focus_dirs)} focus directories") 188 | print(f"File saved to: {config_auto_path}") 189 | 190 | return focus_dirs 191 | 192 | class ProjectTreeGenerator: 193 | def __init__(self, project_root: Path, config_dir: Path): 194 | """ 195 | Initializes the generator with gitignore-based exclusions and the project root. 196 | """ 197 | self.project_root = project_root 198 | self.config_dir = config_dir 199 | 200 | # Load config from YAML in the config directory 201 | config_path = config_dir / 'config.yaml' 202 | with open(config_path, 'r') as f: 203 | config = yaml.safe_load(f) 204 | 205 | # Set file extensions from config 206 | self.INCLUDE_EXTENSIONS: Set[str] = set(config.get('include_extensions', [])) 207 | self.IMPORTANT_DIRS = set(config.get('important_dirs', [])) 208 | self.EXCLUDE_DIRS = set(config.get('exclude_dirs', [])) 209 | 210 | # Initialize gitignore matcher 211 | gitignore_path = project_root / '.gitignore' 212 | if gitignore_path.exists(): 213 | self.matches = parse_gitignore(gitignore_path) 214 | else: 215 | # Create temporary gitignore with exclude_dirs from config 216 | temp_gitignore = project_root / '.temp_gitignore' 217 | with open(temp_gitignore, 'w') as f: 218 | f.write('\n'.join(f'{dir}/' for dir in self.EXCLUDE_DIRS)) 219 | self.matches = parse_gitignore(temp_gitignore) 220 | temp_gitignore.unlink() 221 | 222 | def generate_tree(self, directory: Path, file_types: List[str] = None, max_depth: int = 3, skip_dirs: Set[str] = None, config_paths: Set[str] = None) -> List[str]: 223 | """ 224 | Generates a visual tree representation of the directory structure. 225 | 226 | Args: 227 | directory: Directory to generate tree for 228 | file_types: List of file extensions to include 229 | max_depth: Maximum depth to traverse 230 | skip_dirs: Set of directory paths to skip (already processed in parent trees) 231 | config_paths: Set of all paths from config.yaml for exclusion checking 232 | """ 233 | tree_lines = [] 234 | skip_dirs = skip_dirs or set() 235 | config_paths = config_paths or set() 236 | 237 | def _generate_tree(dir_path: Path, prefix: str = "", depth: int = 0): 238 | if depth > max_depth: 239 | return 240 | 241 | items = sorted(list(dir_path.iterdir()), key=lambda x: (not x.is_file(), x.name)) 242 | for i, item in enumerate(items): 243 | rel_path = str(item.relative_to(self.project_root)) 244 | 245 | if (item.name in self.EXCLUDE_DIRS or 246 | self.matches(str(item)) or 247 | rel_path in skip_dirs or 248 | (item.is_dir() and any(cp.startswith(rel_path) for cp in config_paths))): 249 | print(f"Skipping {rel_path}") # Debug print 250 | continue 251 | 252 | is_last = i == len(items) - 1 253 | display_path = item.name 254 | 255 | if item.is_dir(): 256 | tree_lines.append(f"{prefix}{'└── ' if is_last else '├── '}{display_path}/") 257 | _generate_tree(item, prefix + (' ' if is_last else '│ '), depth + 1) 258 | elif item.is_file(): 259 | extensions_to_check = file_types if file_types else self.INCLUDE_EXTENSIONS 260 | if any(item.name.endswith(ext) for ext in extensions_to_check): 261 | tree_lines.append(f"{prefix}{'└── ' if is_last else '├── '}{display_path}") 262 | 263 | return tree_lines 264 | 265 | return _generate_tree(directory) 266 | 267 | def find_focus_dirs(self, directory: Path, focus_dirs: List[str]) -> List[Path]: 268 | """ 269 | Finds directories matching the focus names, handling nested paths and special cases. 270 | """ 271 | found_dirs = [] 272 | print(f"\n🔍 Looking for focus directories in: {directory}") 273 | 274 | # First, normalize all focus dirs and preserve special paths 275 | normalized_focus_dirs = [] 276 | for fd in focus_dirs: 277 | # Preserve paths with double underscores 278 | if '__' in fd: 279 | normalized_focus_dirs.append(Path(fd)) 280 | # Convert single underscores to paths 281 | elif '_' in fd and '/' not in fd: 282 | normalized_focus_dirs.append(Path(fd.replace('_', '/'))) 283 | else: 284 | normalized_focus_dirs.append(Path(fd)) 285 | 286 | # Sort by path depth (shortest first) to handle parent folders first 287 | normalized_focus_dirs.sort(key=lambda p: len(p.parts)) 288 | 289 | # Try exact directory matching first 290 | for focus_path in normalized_focus_dirs: 291 | try: 292 | # Check for exact path 293 | target_path = (directory / focus_path).resolve() 294 | if target_path.exists() and target_path.is_dir(): 295 | print(f"✅ Found exact path: {target_path}") 296 | found_dirs.append(target_path) 297 | continue 298 | 299 | # Check simple name at top level 300 | simple_path = (directory / focus_path.name).resolve() 301 | if simple_path.exists() and simple_path.is_dir(): 302 | print(f"✅ Found directory by name: {simple_path}") 303 | found_dirs.append(simple_path) 304 | continue 305 | 306 | # Look one level deeper for matching directory name 307 | for item in directory.iterdir(): 308 | if item.is_dir(): 309 | nested_path = (item / focus_path.name).resolve() 310 | if nested_path.exists() and nested_path.is_dir(): 311 | print(f"✅ Found nested directory: {nested_path}") 312 | found_dirs.append(nested_path) 313 | break 314 | 315 | # Still not found - try searching by walking the tree 316 | if not any(focus_path.name in str(d) for d in found_dirs): 317 | print(f"🔍 Searching for '{focus_path.name}' in directory tree...") 318 | # Walk no more than 3 levels deep to find matching directory names 319 | for root, dirs, _ in os.walk(str(directory)): 320 | depth = root[len(str(directory)):].count(os.sep) 321 | if depth > 3: # Limit depth 322 | continue 323 | 324 | for dir_name in dirs: 325 | if dir_name == focus_path.name: 326 | match_path = Path(os.path.join(root, dir_name)) 327 | print(f"✅ Found directory by walking tree: {match_path}") 328 | found_dirs.append(match_path) 329 | break 330 | 331 | # Break after finding first match to avoid too many results 332 | if any(focus_path.name in str(d) for d in found_dirs): 333 | break 334 | 335 | except Exception as e: 336 | print(f"⚠️ Error processing {focus_path}: {str(e)}") 337 | 338 | # If we found no directories, fall back to scanning for code directories 339 | if not found_dirs: 340 | print("\n⚠️ Exact matching failed. Falling back to code directory detection...") 341 | # Look for directories with most code files 342 | code_dirs = self._find_code_directories(directory) 343 | if code_dirs: 344 | found_dirs = code_dirs 345 | 346 | print(f"\n📂 Final directories found: {len(found_dirs)}") 347 | for d in found_dirs: 348 | print(f" - {d}") 349 | 350 | return found_dirs 351 | 352 | def _find_code_directories(self, directory: Path, max_dirs=5) -> List[Path]: 353 | """ 354 | Find directories containing the most code files by scanning the filesystem. 355 | Used as a fallback when directory names aren't found. 356 | """ 357 | print(f"Scanning for code files in {directory}...") 358 | 359 | # Common file extensions to look for 360 | code_extensions = { 361 | '.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', 362 | '.java', '.c', '.cpp', '.h', '.cs', '.go', '.rb', '.php', 363 | '.vue', '.svelte', '.json', '.yaml', '.yml', '.md' 364 | } 365 | 366 | # Directories to exclude 367 | exclude_dirs = { 368 | 'node_modules', 'dist', 'build', '.git', '__pycache__', 369 | 'venv', 'env', '.next', 'out', 'coverage', 'tmp', 'temp' 370 | } 371 | 372 | # Count code files per directory 373 | dir_counts = defaultdict(int) 374 | 375 | try: 376 | # Walk through the directory tree 377 | for root, dirs, files in os.walk(str(directory)): 378 | # Skip excluded directories 379 | dirs[:] = [d for d in dirs if d not in exclude_dirs and not d.startswith('.')] 380 | 381 | # Skip if we're too deep 382 | depth = root[len(str(directory)):].count(os.sep) 383 | if depth > 4: # Limit depth to 4 levels 384 | continue 385 | 386 | # Count code files in this directory 387 | code_file_count = sum(1 for f in files if any(f.endswith(ext) for ext in code_extensions)) 388 | 389 | if code_file_count > 0: 390 | rel_path = os.path.relpath(root, str(directory)) 391 | if rel_path == '.': 392 | continue # Skip root 393 | 394 | # Record directory and count 395 | dir_counts[rel_path] += code_file_count 396 | 397 | # Get the top directories by file count 398 | top_dirs = sorted(dir_counts.items(), key=lambda x: -x[1])[:max_dirs] 399 | 400 | # Convert to Path objects 401 | result = [] 402 | for rel_path, count in top_dirs: 403 | full_path = (directory / rel_path).resolve() 404 | if full_path.exists(): 405 | print(f"✅ Found code directory: {full_path} ({count} files)") 406 | result.append(full_path) 407 | 408 | return result 409 | 410 | except Exception as e: 411 | print(f"⚠️ Error scanning for code directories: {str(e)}") 412 | return [] 413 | 414 | def generate_agent_files(focus_dirs: List[str], config_dir: Path, project_dir: Path, output_dir: Path): 415 | """ 416 | Generates agent-specific markdown files for each focus directory. 417 | """ 418 | created_files = set() 419 | print(f"\n📝 Generating agent files in project directory: {project_dir}") 420 | 421 | # Create a reference to the tree_files directory 422 | tree_files_dir = config_dir / 'tree_files' 423 | 424 | for dir_path in focus_dirs: 425 | try: 426 | # Ensure dir_path is a Path object 427 | if isinstance(dir_path, str): 428 | dir_path = Path(dir_path) 429 | 430 | # Make sure we have a full resolved path 431 | if not dir_path.is_absolute(): 432 | dir_path = (project_dir / dir_path).resolve() 433 | 434 | # Check if the directory exists 435 | if not dir_path.exists() or not dir_path.is_dir(): 436 | print(f"⚠️ Skipping non-existent directory: {dir_path}") 437 | continue 438 | 439 | # Get directory name and parent for agent file naming 440 | dir_name = dir_path.name 441 | 442 | # Calculate relative path to project dir for naming 443 | try: 444 | rel_path = dir_path.relative_to(project_dir) 445 | parent_path = rel_path.parent if rel_path.parent != Path('.') else None 446 | parent_name = parent_path.name if parent_path else None 447 | except ValueError: 448 | # Handle case where dir_path is not relative to project_dir 449 | rel_path = dir_path.name 450 | parent_name = dir_path.parent.name 451 | 452 | # Generate the agent file name based on the path structure 453 | if str(rel_path).count('/') > 0 or str(rel_path).count('\\') > 0: 454 | # Handle paths with depth 455 | parts = str(rel_path).replace('\\', '/').split('/') 456 | agent_name = f"agent_{parts[0]}_{parts[-1]}.md" 457 | elif parent_name and parent_name != project_dir.name and not dir_name.startswith('__'): 458 | agent_name = f"agent_{parent_name}_{dir_name}.md" 459 | else: 460 | agent_name = f"agent_{dir_name}.md" 461 | 462 | if agent_name in created_files: 463 | print(f"⚠️ Skipping duplicate agent file: {agent_name}") 464 | continue 465 | 466 | # Use the last part of the path for the tree file name 467 | # Look in tree_files directory instead of config_dir directly 468 | tree_file = tree_files_dir / f'tree_{dir_name}.txt' 469 | tree_content = "" 470 | if tree_file.exists(): 471 | with open(tree_file, 'r', encoding='utf-8') as f: 472 | tree_content = f.read() 473 | else: 474 | print(f"⚠️ No tree file found at {tree_file}") 475 | 476 | # Generate appropriate directory description 477 | if parent_name and parent_name != project_dir.name: 478 | dir_description = f"the {dir_name} directory within {parent_name}" 479 | else: 480 | dir_description = f"the {dir_name} portion" 481 | 482 | agent_content = f"""You are an agent that specializes in {dir_description} of this project. Your expertise and responses should focus specifically on the code and files within this directory structure: 483 | 484 | {tree_content} 485 | 486 | When providing assistance, only reference and modify files within this directory structure. If you need to work with files outside this structure, list the required files and ask the user for permission first.""" 487 | 488 | # Save to project directory 489 | output_path = project_dir / agent_name 490 | with open(output_path, 'w', encoding='utf-8') as f: 491 | f.write(agent_content) 492 | print(f"✅ Created {output_path}") 493 | 494 | created_files.add(agent_name) 495 | 496 | except Exception as e: 497 | print(f"❌ Error processing directory '{dir_path}': {str(e)}") 498 | import traceback 499 | traceback.print_exc() 500 | 501 | def add_arguments(parser): 502 | """Add command-line arguments to the parser.""" 503 | parser.add_argument('--recurring', action='store_true', 504 | help='Run the script every minute') 505 | parser.add_argument('--project-path', type=str, 506 | help='Path to the target project directory') 507 | parser.add_argument('--tree-input', action='store_true', 508 | help='Provide a tree structure to generate config') 509 | parser.add_argument('--auto-config', action='store_true', 510 | help='Automatically generate config from filesystem') 511 | parser.add_argument('--verify-config', action='store_true', 512 | help='Print the current config.yaml content') 513 | parser.add_argument('--project-title', type=str, default="cursorrules-agentic", 514 | help='Project title for generated config') 515 | parser.add_argument('--use-detected', action='store_true', 516 | help='Use detected_config.yaml if available') 517 | parser.add_argument('--local-agents', action='store_true', 518 | help='Store agent files in script directory instead of project directory') 519 | 520 | if __name__ == "__main__": 521 | try: 522 | parser = argparse.ArgumentParser() 523 | add_arguments(parser) 524 | args = parser.parse_args() 525 | 526 | # Get the config directory (where the script is located) 527 | config_dir = Path(__file__).parent 528 | 529 | # Set project directory from argument or use parent of config dir 530 | project_dir = Path(args.project_path).resolve() if args.project_path else config_dir.parent.resolve() 531 | print(f"Using project directory: {project_dir} (absolute path)") 532 | 533 | # Determine which config file to use 534 | if args.auto_config: 535 | config_path = config_dir / 'config_auto.yaml' 536 | print(f"Using auto-generated config: {config_path}") 537 | elif args.use_detected and (config_dir / 'detected_config.yaml').exists(): 538 | config_path = config_dir / 'detected_config.yaml' 539 | print(f"Using detected config: {config_path}") 540 | else: 541 | config_path = config_dir / 'config_manual.yaml' 542 | print(f"Using manual config: {config_path}") 543 | 544 | # Verify config if requested 545 | if args.verify_config: 546 | if config_path.exists(): 547 | print(f"\nCurrent config content at {config_path}:") 548 | print("-" * 40) 549 | with open(config_path, 'r') as f: 550 | print(f.read()) 551 | print("-" * 40) 552 | else: 553 | print(f"\n❌ No config found at {config_path}") 554 | sys.exit(0) 555 | 556 | # If tree input mode is enabled, handle that first 557 | if args.tree_input: 558 | print("Please paste your file tree below (Ctrl+D or Ctrl+Z+Enter when done):") 559 | tree_text = "" 560 | try: 561 | while True: 562 | line = input() 563 | tree_text += line + "\n" 564 | except (EOFError, KeyboardInterrupt): 565 | pass 566 | 567 | if tree_text.strip(): 568 | # Use the ConfigUpdater to process the tree 569 | updater = ConfigUpdater(config_dir) 570 | success = updater.from_tree_text(tree_text, args.project_title) 571 | 572 | if not success: 573 | print("❌ Failed to update config from tree text") 574 | sys.exit(1) 575 | 576 | # If we're just updating config, exit 577 | if input("Continue with agent generation? (y/n): ").lower() != 'y': 578 | sys.exit(0) 579 | else: 580 | print("❌ No tree text provided.") 581 | sys.exit(1) 582 | 583 | # Auto-config from filesystem 584 | elif args.auto_config: 585 | analyzer = SmartCodeAnalyzer(project_dir, config_dir) 586 | focus_dirs = analyzer.analyze() 587 | print(f"Smart structure analysis complete!") 588 | 589 | # Verify the config was updated 590 | if config_path.exists(): 591 | print("\nVerifying config contents:") 592 | with open(config_path, 'r') as f: 593 | for i, line in enumerate(f): 594 | if i < 15: # First 15 lines 595 | print(f" {line.rstrip()}") 596 | else: 597 | print(" ...") 598 | break 599 | 600 | # If we're just updating config, exit 601 | if input("\nContinue with agent generation? (y/n): ").lower() != 'y': 602 | sys.exit(0) 603 | 604 | # Create default config file if it doesn't exist 605 | if not config_path.exists(): 606 | # If detected_config exists and we want to use it, copy it 607 | if args.use_detected and (config_dir / 'detected_config.yaml').exists(): 608 | shutil.copy2(config_dir / 'detected_config.yaml', config_path) 609 | print(f"Copied detected_config.yaml to {config_path}") 610 | else: 611 | # Create default config 612 | default_config = { 613 | 'project_title': args.project_title, 614 | 'tree_focus': ['api', 'app'] 615 | } 616 | with open(config_path, 'w') as f: 617 | yaml.dump(default_config, f, default_flow_style=False) 618 | print(f"Created default config at {config_path} with {', '.join(default_config['tree_focus'])} focus directories") 619 | 620 | # Ensure project directory exists 621 | if not project_dir.exists(): 622 | print(f"Error: Project directory {project_dir} does not exist") 623 | sys.exit(1) 624 | 625 | # Copy .cursorrules to project directory if it doesn't exist 626 | cursorrules_example = config_dir / '.cursorrules.example' 627 | project_cursorrules = project_dir / '.cursorrules' 628 | if not project_cursorrules.exists() and cursorrules_example.exists(): 629 | shutil.copy2(cursorrules_example, project_cursorrules) 630 | print(f"Copied .cursorrules to {project_cursorrules}") 631 | 632 | while True: # Add while loop for recurring execution 633 | # Load config with error handling 634 | try: 635 | with open(config_path, 'r') as f: 636 | config = yaml.safe_load(f) 637 | if not isinstance(config, dict) or 'tree_focus' not in config: 638 | raise ValueError("Invalid config format: 'tree_focus' list is required") 639 | focus_dirs = config.get('tree_focus', []) 640 | if not isinstance(focus_dirs, list): 641 | raise ValueError("'tree_focus' must be a list of directories") 642 | except Exception as e: 643 | print(f"Error loading config.yaml: {str(e)}") 644 | print("Using default configuration...") 645 | focus_dirs = ['api', 'app'] 646 | 647 | generator = ProjectTreeGenerator(project_dir, config_dir) 648 | 649 | # Generate tree for each focus directory 650 | found_dirs = generator.find_focus_dirs(project_dir, focus_dirs) 651 | 652 | # Keep track of processed directories 653 | processed_dirs = set() 654 | 655 | # Create a set of all configured paths for exclusion checking 656 | config_paths = {str(Path(fd)) for fd in focus_dirs} 657 | 658 | for focus_dir in found_dirs: 659 | # Calculate relative path from project root 660 | rel_path = focus_dir.relative_to(project_dir) 661 | 662 | # Skip if this directory is already included in a parent tree 663 | if any(str(rel_path).startswith(str(pd)) for pd in processed_dirs 664 | if not any(part.startswith('__') for part in rel_path.parts)): 665 | continue 666 | 667 | print(f"\nTree for {focus_dir.name}:") 668 | print("=" * (len(focus_dir.name) + 9)) 669 | 670 | # Generate skip_dirs for subdirectories that will be processed separately 671 | skip_dirs = {str(d.relative_to(project_dir)) for d in found_dirs 672 | if str(d.relative_to(project_dir)).startswith(str(rel_path)) 673 | and d != focus_dir 674 | and any(part.startswith('__') for part in d.relative_to(project_dir).parts)} 675 | 676 | # Pass the config_paths to generate_tree 677 | tree_content = generator.generate_tree( 678 | focus_dir, 679 | skip_dirs=skip_dirs, 680 | config_paths=config_paths 681 | ) 682 | print('\n'.join(tree_content)) 683 | 684 | # Create tree_files directory if it doesn't exist 685 | tree_files_dir = config_dir / 'tree_files' 686 | tree_files_dir.mkdir(exist_ok=True) 687 | 688 | # Save tree files in tree_files directory 689 | with open(tree_files_dir / f'tree_{focus_dir.name}.txt', 'w', encoding='utf-8') as f: 690 | f.write('\n'.join(tree_content)) 691 | 692 | processed_dirs.add(rel_path) 693 | 694 | # Generate agent files in project directory 695 | output_dir = config_dir if args.local_agents else project_dir 696 | generate_agent_files([str(d.relative_to(project_dir)) for d in found_dirs], config_dir, project_dir, output_dir) 697 | 698 | if not args.recurring: 699 | break 700 | 701 | time.sleep(60) # Wait for 1 minute before next iteration 702 | 703 | except Exception as e: 704 | print(f"Fatal error: {str(e)}") 705 | sys.exit(1) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | gitignore-parser 3 | -------------------------------------------------------------------------------- /smart_analyzer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | import yaml 4 | from collections import Counter 5 | import functools 6 | import json 7 | import urllib.request 8 | import time 9 | 10 | # Cache the extensions to avoid repeated network requests 11 | @functools.lru_cache(maxsize=1) 12 | def get_code_extensions(): 13 | """Get all programming language file extensions from a comprehensive GitHub list.""" 14 | extensions = set() 15 | backup_extensions = { 16 | # Web development 17 | '.html', '.htm', '.xhtml', '.css', '.scss', '.sass', '.less', 18 | '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.php', '.asp', '.aspx', 19 | 20 | # Python 21 | '.py', '.pyx', '.pyd', '.pyi', '.pyw', '.rpy', '.cpy', '.gyp', '.ipynb', 22 | 23 | # Java ecosystem 24 | '.java', '.class', '.jar', '.jsp', '.jspx', '.properties', '.groovy', '.gradle', '.kt', '.kts', 25 | 26 | # C/C++/C# 27 | '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.c++', '.hxx', '.h++', 28 | '.cs', '.csproj', '.fs', '.fsx', '.fsi', 29 | 30 | # Ruby 31 | '.rb', '.erb', '.gemspec', '.rake', '.ru', 32 | 33 | # Go 34 | '.go', '.mod', 35 | 36 | # Rust 37 | '.rs', '.rlib', '.toml', 38 | 39 | # Swift/Objective-C 40 | '.swift', '.m', '.mm', 41 | 42 | # Shell/Bash/PowerShell 43 | '.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.psd1', 44 | 45 | # Perl/PHP 46 | '.pl', '.pm', '.t', '.php', '.phtml', 47 | 48 | # Lua/Tcl/Forth 49 | '.lua', '.tcl', '.tk', '.fth', '.4th', 50 | 51 | # Configuration files 52 | '.json', '.yaml', '.yml', '.xml', '.ini', '.cfg', '.conf', '.config', 53 | '.toml', '.properties', '.env', '.rc', '.editorconfig', 54 | 55 | # Documentation 56 | '.md', '.markdown', '.rst', '.adoc', '.asciidoc', '.txt', '.textile', 57 | 58 | # Functional languages 59 | '.hs', '.lhs', '.elm', '.ml', '.mli', '.fs', '.fsi', '.fsx', '.fsscript', 60 | '.clj', '.cljs', '.cljc', '.edn', '.ex', '.exs', '.erl', '.hrl', 61 | 62 | # Other languages 63 | '.dart', '.r', '.rmd', '.jl', '.v', '.ada', '.adb', '.ads', '.d', 64 | '.nim', '.nims', '.cr', '.io', '.sml', '.sig', '.fun', '.scm', '.rkt', 65 | '.lisp', '.cl', '.el', '.elc', '.awk', '.tcl', '.tk', '.vhd', '.vhdl', 66 | '.spin', '.plm', '.elua', '.xc', '.mps', '.purs', '.b4x', '.gdscript', 67 | '.as', '.asc', '.angelscript', '.hx', '.hxml', '.rebol', '.r3', '.st', 68 | '.scratch', '.logo', '.pl', '.pro', '.m', '.apl', '.vala', '.e', 69 | '.rex', '.rexx', '.ps', '.sml', '.sig', '.alice', '.io', '.e', 70 | 71 | # Mobile development 72 | '.swift', '.m', '.mm', '.java', '.kt', '.gradle', 73 | '.plist', '.pbxproj', '.storyboard', '.xib', 74 | 75 | # Data/Analytics 76 | '.sas', '.mat', '.sql', '.spss', '.dax', '.j', 77 | 78 | # Game development 79 | '.gd', '.cs', '.lua', '.js', '.ts', '.cpp', '.h', '.unity', 80 | '.unityproj', '.prefab', '.scene', '.mat', '.anim', 81 | 82 | } 83 | 84 | try: 85 | # URL for the raw JSON file of programming language extensions 86 | url = "https://gist.githubusercontent.com/ppisarczyk/43962d06686722d26d176fad46879d41/raw/Programming_Languages_Extensions.json" 87 | 88 | print(f"Downloading language extensions from ppisarczyk's Programming_Languages_Extensions.json") 89 | # Download the JSON data with a timeout 90 | with urllib.request.urlopen(url, timeout=3) as response: 91 | data = json.loads(response.read()) 92 | 93 | # Extract all extensions from the JSON data, handling multiple extensions per key 94 | for language in data: 95 | if "extensions" in language: 96 | for ext in language["extensions"]: 97 | # Handle comma-separated extensions 98 | if ',' in ext: 99 | for sub_ext in ext.split(','): 100 | sub_ext = sub_ext.strip() 101 | if sub_ext.startswith("."): 102 | extensions.add(sub_ext) 103 | # Handle single extensions 104 | elif ext.startswith("."): 105 | extensions.add(ext) 106 | 107 | print(f"Loaded {len(extensions)} file extensions from GitHub") 108 | 109 | # Add some common config/documentation extensions that might not be in the list 110 | additional = {'.md', '.json', '.yaml', '.yml', '.toml', '.xml', '.ini', '.conf', '.config', 111 | '.env', '.rst', '.txt', '.lock', '.dockerfile', '.ipynb'} 112 | extensions.update(additional) 113 | 114 | return extensions 115 | 116 | except Exception as e: 117 | print(f"Error fetching language extensions: {e}") 118 | print("Using backup extension list instead") 119 | return backup_extensions 120 | 121 | class SmartCodeAnalyzer: 122 | """Smart code analyzer for efficient directory structure detection.""" 123 | 124 | def __init__(self, project_dir, config_dir): 125 | self.project_dir = Path(project_dir) 126 | self.config_dir = Path(config_dir) 127 | self.config_path = config_dir / 'config.yaml' 128 | self.code_files = [] 129 | self.code_dirs = Counter() 130 | 131 | # Common patterns and excluded directories 132 | self.standard_dirs = ['src', 'app', 'components', 'lib', 'utils', 'api'] 133 | self.exclude_dirs = { 134 | 'node_modules', 'dist', 'build', '.git', '__pycache__', 135 | 'venv', 'env', '.next', 'out', 'coverage', 'tmp', 'temp' 136 | } 137 | 138 | # Get all code file extensions 139 | self.extensions = get_code_extensions() 140 | print(f"Initialized with {len(self.extensions)} code file extensions") 141 | 142 | def analyze(self): 143 | """Perform multi-phase analysis of the project structure.""" 144 | print(f"\n🔍 Analyzing project structure: {self.project_dir}") 145 | 146 | # Phase 1: Check for standard src directory 147 | focus_dirs = self._check_src_directory() 148 | 149 | # If we didn't find src pattern, do a more thorough scan 150 | if not focus_dirs: 151 | print("No standard src directory found. Performing detailed scan...") 152 | focus_dirs = self._scan_for_code_directories() 153 | 154 | # If we still don't have directories, use fallback 155 | if not focus_dirs: 156 | print("No code directories found. Using fallback structure...") 157 | focus_dirs = self._fallback_structure() 158 | 159 | # Update the config file 160 | self._update_config(focus_dirs) 161 | return focus_dirs 162 | 163 | def _check_src_directory(self): 164 | """Check for standard src directory pattern.""" 165 | focus_dirs = [] 166 | 167 | # Check for src directory 168 | src_dir = self.project_dir / 'src' 169 | if src_dir.exists() and src_dir.is_dir(): 170 | print("Found 'src' directory. Checking subdirectories...") 171 | focus_dirs.append('src') 172 | 173 | # Check important immediate subdirectories 174 | for item in src_dir.iterdir(): 175 | if item.is_dir() and not item.name.startswith('.'): 176 | # Add src subdirectories as focus directories 177 | rel_path = f"src/{item.name}" 178 | focus_dirs.append(rel_path) 179 | print(f" ✅ Added subdirectory: {rel_path}") 180 | 181 | # Check for other common top-level directories 182 | for dir_name in self.standard_dirs: 183 | if dir_name != 'src': # Already checked src 184 | dir_path = self.project_dir / dir_name 185 | if dir_path.exists() and dir_path.is_dir(): 186 | focus_dirs.append(dir_name) 187 | print(f"✅ Found standard directory: {dir_name}") 188 | 189 | return focus_dirs 190 | 191 | def _scan_for_code_directories(self): 192 | """Scan the project for directories with code files.""" 193 | print("Scanning for code files...") 194 | 195 | # Walk through the directory tree 196 | for root, dirs, files in os.walk(str(self.project_dir)): 197 | # Skip excluded directories 198 | dirs[:] = [d for d in dirs if d not in self.exclude_dirs and not d.startswith('.')] 199 | 200 | # Check each file 201 | for file in files: 202 | # Check if the file has a code extension 203 | if any(file.endswith(ext) for ext in self.extensions): 204 | rel_path = os.path.relpath(os.path.join(root, file), str(self.project_dir)) 205 | self.code_files.append(rel_path) 206 | 207 | # Count files in each directory 208 | dir_path = os.path.dirname(rel_path) 209 | if dir_path: 210 | self.code_dirs[dir_path] += 1 211 | 212 | # Also count parent directories 213 | parent = dir_path 214 | while '/' in parent: 215 | parent = os.path.dirname(parent) 216 | self.code_dirs[parent] += 1 217 | 218 | # Identify significant directories (with at least 3 code files) 219 | significant_dirs = {d: count for d, count in self.code_dirs.items() 220 | if count >= 3 and d} 221 | 222 | if not significant_dirs: 223 | return [] 224 | 225 | # Select top directories 226 | focus_dirs = [] 227 | 228 | # Level 1: Top-level directories first 229 | top_level = sorted([d for d in significant_dirs if '/' not in d], 230 | key=lambda d: -significant_dirs[d]) 231 | focus_dirs.extend(top_level) 232 | 233 | # Level 2: Add important subdirectories 234 | if len(top_level) <= 3: # Only add subdirs if we don't have too many top dirs 235 | for top_dir in top_level: 236 | # Find subdirectories of this top directory 237 | subdirs = [d for d in significant_dirs if d.startswith(f"{top_dir}/")] 238 | 239 | # Take up to 2 most significant subdirectories 240 | subdirs = sorted(subdirs, key=lambda d: -significant_dirs[d])[:2] 241 | focus_dirs.extend(subdirs) 242 | 243 | print(f"Found {len(focus_dirs)} directories with code:") 244 | for d in focus_dirs: 245 | print(f" - {d} ({self.code_dirs[d]} files)") 246 | 247 | return focus_dirs 248 | 249 | def _fallback_structure(self): 250 | """Use fallback structure if no code directories found.""" 251 | focus_dirs = [] 252 | 253 | # Check for any directories at the top level 254 | for item in self.project_dir.iterdir(): 255 | if item.is_dir() and not item.name.startswith('.') and item.name not in self.exclude_dirs: 256 | focus_dirs.append(item.name) 257 | 258 | return focus_dirs 259 | 260 | def _update_config(self, focus_dirs): 261 | """Update config.yaml with the identified focus directories.""" 262 | # Load existing config if available 263 | try: 264 | with open(self.config_path, 'r') as f: 265 | config = yaml.safe_load(f) or {} 266 | except FileNotFoundError: 267 | config = {} 268 | 269 | # Update with new values 270 | config['project_title'] = self.project_dir.name 271 | config['tree_focus'] = focus_dirs 272 | 273 | # Ensure exclude_dirs exists 274 | if 'exclude_dirs' not in config: 275 | config['exclude_dirs'] = list(self.exclude_dirs) 276 | 277 | # Ensure ordered structure (project_title first, tree_focus second) 278 | ordered_config = {} 279 | ordered_config['project_title'] = config.pop('project_title') 280 | ordered_config['tree_focus'] = config.pop('tree_focus') 281 | ordered_config.update(config) # Add remaining sections 282 | 283 | # Write the updated config 284 | with open(self.config_path, 'w') as f: 285 | yaml.dump(ordered_config, f, default_flow_style=False, sort_keys=False) 286 | 287 | print(f"\n✅ Updated config.yaml with {len(focus_dirs)} focus directories") 288 | print(f"File saved to: {self.config_path}") 289 | return focus_dirs -------------------------------------------------------------------------------- /tests/test_dir_structure.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | 5 | def explore_directory(target_dir, max_depth=2): 6 | """List all directories in the target project up to a specified depth.""" 7 | target_dir = Path(target_dir).resolve() 8 | print(f"Exploring directory structure of: {target_dir}\n") 9 | 10 | def _explore(current_dir, depth=0): 11 | if depth > max_depth: 12 | return 13 | 14 | if depth == 0: 15 | print(f"Root: {current_dir}") 16 | 17 | # List immediate subdirectories 18 | subdirs = [d for d in current_dir.iterdir() if d.is_dir() and not d.name.startswith('.')] 19 | for subdir in sorted(subdirs): 20 | indent = " " * depth 21 | print(f"{indent}├── {subdir.name}/") 22 | _explore(subdir, depth + 1) 23 | 24 | _explore(target_dir) 25 | 26 | if __name__ == "__main__": 27 | # Get target directory from command line or use default 28 | target_dir = sys.argv[1] if len(sys.argv) > 1 else "../cursor-chat-browser" 29 | explore_directory(target_dir) -------------------------------------------------------------------------------- /tests/test_directory_finder.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | 5 | def find_directories(project_dir, target_dirs): 6 | """Find directories using multiple search strategies.""" 7 | project_dir = Path(project_dir).resolve() 8 | print(f"Looking for directories in: {project_dir}") 9 | print(f"Target directories: {target_dirs}") 10 | 11 | found_dirs = [] 12 | 13 | # Strategy 1: Direct lookup at the top level 14 | print("\nStrategy 1: Direct top-level lookup") 15 | for target in target_dirs: 16 | path = project_dir / target 17 | if path.exists() and path.is_dir(): 18 | print(f"✅ Found at top level: {path}") 19 | found_dirs.append(path) 20 | else: 21 | print(f"❌ Not found at top level: {target}") 22 | 23 | # Strategy 2: Search one level down 24 | print("\nStrategy 2: One level down search") 25 | for item in project_dir.iterdir(): 26 | if item.is_dir() and not item.name.startswith('.'): 27 | for target in target_dirs: 28 | path = item / target 29 | if path.exists() and path.is_dir(): 30 | print(f"✅ Found one level down: {path}") 31 | found_dirs.append(path) 32 | 33 | # Strategy 3: Case-insensitive search 34 | print("\nStrategy 3: Case-insensitive search") 35 | for target in target_dirs: 36 | target_lower = target.lower() 37 | for item in project_dir.rglob("*"): 38 | if item.is_dir() and item.name.lower() == target_lower: 39 | print(f"✅ Found with case-insensitive search: {item}") 40 | found_dirs.append(item) 41 | 42 | # Strategy 4: Partial name match 43 | print("\nStrategy 4: Partial name match") 44 | for target in target_dirs: 45 | for item in project_dir.rglob("*"): 46 | if item.is_dir() and target.lower() in item.name.lower(): 47 | print(f"✅ Found with partial name match: {item}") 48 | found_dirs.append(item) 49 | 50 | print(f"\nTotal found directories: {len(set(found_dirs))}") 51 | for dir_path in sorted(set(found_dirs)): 52 | print(f" - {dir_path}") 53 | 54 | return found_dirs 55 | 56 | if __name__ == "__main__": 57 | # Get target directory and focus dirs from command line 58 | project_dir = sys.argv[1] if len(sys.argv) > 1 else "../cursor-chat-browser" 59 | target_dirs = sys.argv[2:] if len(sys.argv) > 2 else ["api", "app", "src"] 60 | 61 | find_directories(project_dir, target_dirs) -------------------------------------------------------------------------------- /tests/test_walk_finder.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | from collections import Counter 5 | import yaml 6 | 7 | def analyze_project_structure(project_dir): 8 | """Analyze project structure to find directories with code files.""" 9 | project_dir = Path(project_dir).resolve() 10 | print(f"Analyzing project structure of: {project_dir}") 11 | 12 | # Common code file extensions 13 | code_extensions = { 14 | '.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', 15 | '.java', '.c', '.cpp', '.h', '.cs', '.go', '.rb', '.php', 16 | '.vue', '.svelte', '.json', '.yaml', '.yml', '.md' 17 | } 18 | 19 | # Directories to exclude 20 | exclude_dirs = { 21 | 'node_modules', 'dist', 'build', '.git', '__pycache__', 22 | 'venv', 'env', '.next', 'out' 23 | } 24 | 25 | code_files = [] 26 | dir_counts = Counter() 27 | 28 | # Walk the directory tree 29 | for root, dirs, files in os.walk(str(project_dir)): 30 | # Skip excluded directories 31 | dirs[:] = [d for d in dirs if d not in exclude_dirs and not d.startswith('.')] 32 | 33 | # Count code files 34 | for file in files: 35 | file_ext = os.path.splitext(file)[1].lower() 36 | if file_ext in code_extensions: 37 | rel_path = os.path.relpath(root, str(project_dir)) 38 | if rel_path == '.': 39 | rel_path = '' 40 | 41 | # Count each directory 42 | if rel_path: 43 | dir_counts[rel_path] += 1 44 | 45 | # Also count parent directories 46 | parts = rel_path.split(os.sep) 47 | for i in range(1, len(parts)): 48 | parent = os.sep.join(parts[:i]) 49 | dir_counts[parent] += 1 50 | 51 | # Get top-level directories 52 | top_dirs = [d for d in dir_counts.keys() if not os.sep in d] 53 | top_dirs = sorted(top_dirs, key=lambda d: -dir_counts[d]) 54 | 55 | # Get important subdirectories 56 | important_subdirs = [] 57 | for dir_path, count in dir_counts.items(): 58 | if os.sep in dir_path and count >= 5: # Only significant subdirs 59 | important_subdirs.append(dir_path) 60 | 61 | important_subdirs = sorted(important_subdirs, key=lambda d: -dir_counts[d])[:10] # Top 10 62 | 63 | # Combine for focus directories 64 | focus_dirs = top_dirs[:5] + important_subdirs # Top 5 top-level + important subdirs 65 | 66 | print("\nMost important directories (by code file count):") 67 | for dir_path in focus_dirs: 68 | print(f" - {dir_path} ({dir_counts[dir_path]} code files)") 69 | 70 | # Generate config.yaml 71 | config = { 72 | 'project_title': project_dir.name, 73 | 'tree_focus': focus_dirs, 74 | 'exclude_dirs': list(exclude_dirs) 75 | } 76 | 77 | config_path = Path('detected_config.yaml') 78 | with open(config_path, 'w') as f: 79 | yaml.dump(config, f, default_flow_style=False, sort_keys=False) 80 | 81 | print(f"\nGenerated config.yaml at {config_path.resolve()}") 82 | print(f"Found {len(focus_dirs)} important directories") 83 | 84 | return focus_dirs 85 | 86 | if __name__ == "__main__": 87 | # Get target directory from command line 88 | project_dir = sys.argv[1] if len(sys.argv) > 1 else "../cursor-chat-browser" 89 | analyze_project_structure(project_dir) --------------------------------------------------------------------------------