claude-dev-cli 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of claude-dev-cli might be problematic. Click here for more details.

@@ -0,0 +1,494 @@
1
+ """Intelligent context gathering for AI operations."""
2
+
3
+ import ast
4
+ import json
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Set
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass
13
+ class ContextItem:
14
+ """A single piece of context information."""
15
+ type: str # 'file', 'git', 'dependency', 'error'
16
+ content: str
17
+ metadata: Dict[str, Any] = field(default_factory=dict)
18
+
19
+ def format_for_prompt(self) -> str:
20
+ """Format this context item for inclusion in a prompt."""
21
+ if self.type == 'file':
22
+ path = self.metadata.get('path', 'unknown')
23
+ return f"# File: {path}\n\n{self.content}\n"
24
+ elif self.type == 'git':
25
+ return f"# Git Context\n\n{self.content}\n"
26
+ elif self.type == 'dependency':
27
+ return f"# Dependencies\n\n{self.content}\n"
28
+ elif self.type == 'error':
29
+ return f"# Error Context\n\n{self.content}\n"
30
+ else:
31
+ return self.content
32
+
33
+
34
+ @dataclass
35
+ class Context:
36
+ """Collection of context items."""
37
+ items: List[ContextItem] = field(default_factory=list)
38
+
39
+ def add(self, item: ContextItem) -> None:
40
+ """Add a context item."""
41
+ self.items.append(item)
42
+
43
+ def format_for_prompt(self) -> str:
44
+ """Format all context items for inclusion in a prompt."""
45
+ if not self.items:
46
+ return ""
47
+
48
+ parts = ["# Context Information\n"]
49
+ for item in self.items:
50
+ parts.append(item.format_for_prompt())
51
+
52
+ return "\n".join(parts)
53
+
54
+ def get_by_type(self, context_type: str) -> List[ContextItem]:
55
+ """Get all context items of a specific type."""
56
+ return [item for item in self.items if item.type == context_type]
57
+
58
+
59
+ class GitContext:
60
+ """Gather Git-related context."""
61
+
62
+ def __init__(self, cwd: Optional[Path] = None):
63
+ self.cwd = cwd or Path.cwd()
64
+
65
+ def is_git_repo(self) -> bool:
66
+ """Check if current directory is a git repository."""
67
+ try:
68
+ result = subprocess.run(
69
+ ['git', 'rev-parse', '--git-dir'],
70
+ cwd=self.cwd,
71
+ capture_output=True,
72
+ text=True
73
+ )
74
+ return result.returncode == 0
75
+ except Exception:
76
+ return False
77
+
78
+ def get_current_branch(self) -> Optional[str]:
79
+ """Get the current git branch."""
80
+ try:
81
+ result = subprocess.run(
82
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
83
+ cwd=self.cwd,
84
+ capture_output=True,
85
+ text=True,
86
+ check=True
87
+ )
88
+ return result.stdout.strip()
89
+ except Exception:
90
+ return None
91
+
92
+ def get_recent_commits(self, count: int = 5) -> List[Dict[str, str]]:
93
+ """Get recent commit messages."""
94
+ try:
95
+ result = subprocess.run(
96
+ ['git', '--no-pager', 'log', f'-{count}', '--pretty=format:%h|%s|%an|%ar'],
97
+ cwd=self.cwd,
98
+ capture_output=True,
99
+ text=True,
100
+ check=True
101
+ )
102
+
103
+ commits = []
104
+ for line in result.stdout.strip().split('\n'):
105
+ if line:
106
+ parts = line.split('|', 3)
107
+ if len(parts) == 4:
108
+ commits.append({
109
+ 'hash': parts[0],
110
+ 'message': parts[1],
111
+ 'author': parts[2],
112
+ 'date': parts[3]
113
+ })
114
+ return commits
115
+ except Exception:
116
+ return []
117
+
118
+ def get_staged_diff(self) -> Optional[str]:
119
+ """Get diff of staged changes."""
120
+ try:
121
+ result = subprocess.run(
122
+ ['git', '--no-pager', 'diff', '--cached'],
123
+ cwd=self.cwd,
124
+ capture_output=True,
125
+ text=True,
126
+ check=True
127
+ )
128
+ return result.stdout if result.stdout else None
129
+ except Exception:
130
+ return None
131
+
132
+ def get_unstaged_diff(self) -> Optional[str]:
133
+ """Get diff of unstaged changes."""
134
+ try:
135
+ result = subprocess.run(
136
+ ['git', '--no-pager', 'diff'],
137
+ cwd=self.cwd,
138
+ capture_output=True,
139
+ text=True,
140
+ check=True
141
+ )
142
+ return result.stdout if result.stdout else None
143
+ except Exception:
144
+ return None
145
+
146
+ def get_modified_files(self) -> List[str]:
147
+ """Get list of modified files."""
148
+ try:
149
+ result = subprocess.run(
150
+ ['git', 'status', '--porcelain'],
151
+ cwd=self.cwd,
152
+ capture_output=True,
153
+ text=True,
154
+ check=True
155
+ )
156
+
157
+ files = []
158
+ for line in result.stdout.strip().split('\n'):
159
+ if line:
160
+ # Format: "XY filename"
161
+ parts = line.strip().split(maxsplit=1)
162
+ if len(parts) == 2:
163
+ files.append(parts[1])
164
+ return files
165
+ except Exception:
166
+ return []
167
+
168
+ def gather(self, include_diff: bool = False) -> ContextItem:
169
+ """Gather all git context."""
170
+ parts = []
171
+
172
+ branch = self.get_current_branch()
173
+ if branch:
174
+ parts.append(f"Branch: {branch}")
175
+
176
+ commits = self.get_recent_commits(5)
177
+ if commits:
178
+ parts.append("\nRecent commits:")
179
+ for commit in commits:
180
+ parts.append(f" {commit['hash']} - {commit['message']} ({commit['date']})")
181
+
182
+ modified = self.get_modified_files()
183
+ if modified:
184
+ parts.append(f"\nModified files: {', '.join(modified[:10])}")
185
+
186
+ if include_diff:
187
+ staged = self.get_staged_diff()
188
+ if staged:
189
+ parts.append(f"\nStaged changes:\n{staged[:1000]}...") # Limit size
190
+
191
+ content = "\n".join(parts) if parts else "No git context available"
192
+
193
+ return ContextItem(
194
+ type='git',
195
+ content=content,
196
+ metadata={'branch': branch, 'modified_count': len(modified)}
197
+ )
198
+
199
+
200
+ class DependencyAnalyzer:
201
+ """Analyze project dependencies and imports."""
202
+
203
+ def __init__(self, project_root: Path):
204
+ self.project_root = project_root
205
+
206
+ def find_python_imports(self, file_path: Path) -> Set[str]:
207
+ """Extract imports from a Python file."""
208
+ imports = set()
209
+
210
+ try:
211
+ with open(file_path, 'r') as f:
212
+ tree = ast.parse(f.read())
213
+
214
+ for node in ast.walk(tree):
215
+ if isinstance(node, ast.Import):
216
+ for name in node.names:
217
+ imports.add(name.name.split('.')[0])
218
+ elif isinstance(node, ast.ImportFrom):
219
+ if node.module:
220
+ imports.add(node.module.split('.')[0])
221
+ except Exception:
222
+ pass
223
+
224
+ return imports
225
+
226
+ def find_related_files(self, file_path: Path, max_depth: int = 2) -> List[Path]:
227
+ """Find files related to the given file through imports."""
228
+ if not file_path.suffix == '.py':
229
+ return []
230
+
231
+ related = []
232
+ imports = self.find_python_imports(file_path)
233
+
234
+ # Look for local modules
235
+ for imp in imports:
236
+ # Try as module file
237
+ module_file = self.project_root / f"{imp}.py"
238
+ if module_file.exists() and module_file != file_path:
239
+ related.append(module_file)
240
+
241
+ # Try as package
242
+ package_init = self.project_root / imp / "__init__.py"
243
+ if package_init.exists():
244
+ related.append(package_init)
245
+
246
+ return related[:5] # Limit to avoid too many files
247
+
248
+ def get_dependency_files(self) -> List[Path]:
249
+ """Find dependency configuration files."""
250
+ files = []
251
+
252
+ # Python
253
+ for name in ['requirements.txt', 'setup.py', 'pyproject.toml', 'Pipfile']:
254
+ file = self.project_root / name
255
+ if file.exists():
256
+ files.append(file)
257
+
258
+ # Node.js
259
+ for name in ['package.json', 'package-lock.json']:
260
+ file = self.project_root / name
261
+ if file.exists():
262
+ files.append(file)
263
+
264
+ # Other
265
+ for name in ['Gemfile', 'go.mod', 'Cargo.toml']:
266
+ file = self.project_root / name
267
+ if file.exists():
268
+ files.append(file)
269
+
270
+ return files
271
+
272
+ def gather(self, target_file: Optional[Path] = None) -> ContextItem:
273
+ """Gather dependency context."""
274
+ parts = []
275
+
276
+ # Include dependency files
277
+ dep_files = self.get_dependency_files()
278
+ if dep_files:
279
+ parts.append("Dependency files:")
280
+ for file in dep_files[:3]: # Limit
281
+ parts.append(f" - {file.name}")
282
+ try:
283
+ content = file.read_text()
284
+ # Include only relevant parts
285
+ if file.suffix == '.json':
286
+ data = json.loads(content)
287
+ if 'dependencies' in data:
288
+ parts.append(f" Dependencies: {', '.join(list(data['dependencies'].keys())[:10])}")
289
+ elif file.suffix == '.txt':
290
+ lines = content.split('\n')[:20]
291
+ parts.append(f" Requirements: {', '.join([l.split('==')[0] for l in lines if l and not l.startswith('#')])}")
292
+ except Exception:
293
+ pass
294
+
295
+ # Related files if target specified
296
+ if target_file and target_file.exists():
297
+ related = self.find_related_files(target_file)
298
+ if related:
299
+ parts.append(f"\nRelated files for {target_file.name}:")
300
+ for file in related:
301
+ parts.append(f" - {file.relative_to(self.project_root)}")
302
+
303
+ content = "\n".join(parts) if parts else "No dependency context found"
304
+
305
+ return ContextItem(
306
+ type='dependency',
307
+ content=content,
308
+ metadata={'dependency_files': [str(f) for f in dep_files]}
309
+ )
310
+
311
+
312
+ class ErrorContext:
313
+ """Parse and format error context."""
314
+
315
+ @staticmethod
316
+ def parse_traceback(error_text: str) -> Dict[str, Any]:
317
+ """Parse Python traceback into structured data."""
318
+ lines = error_text.split('\n')
319
+
320
+ # Find traceback start
321
+ traceback_start = -1
322
+ for i, line in enumerate(lines):
323
+ if 'Traceback' in line:
324
+ traceback_start = i
325
+ break
326
+
327
+ if traceback_start == -1:
328
+ return {'raw': error_text}
329
+
330
+ # Extract frames
331
+ frames = []
332
+ current_frame = {}
333
+
334
+ for line in lines[traceback_start + 1:]:
335
+ if line.startswith(' File '):
336
+ if current_frame:
337
+ frames.append(current_frame)
338
+
339
+ # Parse: File "path", line X, in function
340
+ match = re.match(r'\s*File "([^"]+)", line (\d+), in (.+)', line)
341
+ if match:
342
+ current_frame = {
343
+ 'file': match.group(1),
344
+ 'line': int(match.group(2)),
345
+ 'function': match.group(3)
346
+ }
347
+ elif line.startswith(' ') and current_frame:
348
+ current_frame['code'] = line.strip()
349
+ elif line and not line.startswith(' '):
350
+ # Error message
351
+ if current_frame:
352
+ frames.append(current_frame)
353
+ current_frame = {}
354
+
355
+ error_type = line.split(':')[0] if ':' in line else line
356
+ error_message = line.split(':', 1)[1].strip() if ':' in line else ''
357
+
358
+ return {
359
+ 'frames': frames,
360
+ 'error_type': error_type,
361
+ 'error_message': error_message,
362
+ 'raw': error_text
363
+ }
364
+
365
+ return {'frames': frames, 'raw': error_text}
366
+
367
+ @staticmethod
368
+ def format_for_ai(error_text: str) -> str:
369
+ """Format error for AI consumption."""
370
+ parsed = ErrorContext.parse_traceback(error_text)
371
+
372
+ if 'error_type' not in parsed:
373
+ return error_text
374
+
375
+ parts = [
376
+ f"Error Type: {parsed['error_type']}",
377
+ f"Error Message: {parsed.get('error_message', 'N/A')}",
378
+ "\nStack Trace:"
379
+ ]
380
+
381
+ for i, frame in enumerate(parsed.get('frames', []), 1):
382
+ parts.append(f" {i}. {frame.get('file', 'unknown')}:{frame.get('line', '?')} in {frame.get('function', 'unknown')}")
383
+ if 'code' in frame:
384
+ parts.append(f" > {frame['code']}")
385
+
386
+ return "\n".join(parts)
387
+
388
+ def gather(self, error_text: str) -> ContextItem:
389
+ """Gather error context."""
390
+ formatted = self.format_for_ai(error_text)
391
+ parsed = self.parse_traceback(error_text)
392
+
393
+ return ContextItem(
394
+ type='error',
395
+ content=formatted,
396
+ metadata=parsed
397
+ )
398
+
399
+
400
+ class ContextGatherer:
401
+ """Main context gathering coordinator."""
402
+
403
+ def __init__(self, project_root: Optional[Path] = None):
404
+ self.project_root = project_root or Path.cwd()
405
+ self.git = GitContext(self.project_root)
406
+ self.dependencies = DependencyAnalyzer(self.project_root)
407
+ self.error_parser = ErrorContext()
408
+
409
+ def gather_for_file(
410
+ self,
411
+ file_path: Path,
412
+ include_git: bool = True,
413
+ include_dependencies: bool = True,
414
+ include_related: bool = True
415
+ ) -> Context:
416
+ """Gather context for a specific file operation."""
417
+ context = Context()
418
+
419
+ # Add the file itself
420
+ if file_path.exists():
421
+ context.add(ContextItem(
422
+ type='file',
423
+ content=file_path.read_text(),
424
+ metadata={'path': str(file_path)}
425
+ ))
426
+
427
+ # Add git context
428
+ if include_git and self.git.is_git_repo():
429
+ context.add(self.git.gather(include_diff=False))
430
+
431
+ # Add dependency context
432
+ if include_dependencies:
433
+ context.add(self.dependencies.gather(target_file=file_path if include_related else None))
434
+
435
+ return context
436
+
437
+ def gather_for_error(
438
+ self,
439
+ error_text: str,
440
+ file_path: Optional[Path] = None,
441
+ include_git: bool = True
442
+ ) -> Context:
443
+ """Gather context for error debugging."""
444
+ context = Context()
445
+
446
+ # Add error context
447
+ context.add(self.error_parser.gather(error_text))
448
+
449
+ # Add file if provided
450
+ if file_path and file_path.exists():
451
+ context.add(ContextItem(
452
+ type='file',
453
+ content=file_path.read_text(),
454
+ metadata={'path': str(file_path)}
455
+ ))
456
+
457
+ # Add git context
458
+ if include_git and self.git.is_git_repo():
459
+ context.add(self.git.gather(include_diff=False))
460
+
461
+ return context
462
+
463
+ def gather_for_review(
464
+ self,
465
+ file_path: Path,
466
+ include_git: bool = True,
467
+ include_tests: bool = True
468
+ ) -> Context:
469
+ """Gather context for code review."""
470
+ context = self.gather_for_file(
471
+ file_path,
472
+ include_git=include_git,
473
+ include_dependencies=True,
474
+ include_related=True
475
+ )
476
+
477
+ # Try to find test file
478
+ if include_tests:
479
+ test_patterns = [
480
+ self.project_root / "tests" / f"test_{file_path.name}",
481
+ self.project_root / f"test_{file_path.name}",
482
+ file_path.parent / f"test_{file_path.name}"
483
+ ]
484
+
485
+ for test_file in test_patterns:
486
+ if test_file.exists():
487
+ context.add(ContextItem(
488
+ type='file',
489
+ content=test_file.read_text(),
490
+ metadata={'path': str(test_file), 'is_test': True}
491
+ ))
492
+ break
493
+
494
+ return context