pycodesage 0.1.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.
codesage/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """CodeSage: Local-first CLI code intelligence tool."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Keshav Ashiya"
codesage/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m codesage."""
2
+
3
+ from codesage.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,5 @@
1
+ """CLI package exports."""
2
+
3
+ from codesage.cli.main import app
4
+
5
+ __all__ = ["app"]
codesage/cli/main.py ADDED
@@ -0,0 +1,461 @@
1
+ """CodeSage CLI - Main entry point."""
2
+
3
+ # Suppress urllib3 SSL warning on macOS with LibreSSL
4
+ import warnings
5
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
6
+
7
+ import signal
8
+ import sys
9
+ import atexit
10
+ from typing import Callable, List
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.syntax import Syntax
16
+ from pathlib import Path
17
+
18
+ from codesage import __version__
19
+
20
+ # Input validation constants
21
+ MAX_QUERY_LENGTH = 2000
22
+ MAX_PATH_LENGTH = 4096
23
+
24
+ # Graceful shutdown infrastructure
25
+ _cleanup_handlers: List[Callable[[], None]] = []
26
+ _shutdown_in_progress = False
27
+
28
+
29
+ def register_cleanup(handler: Callable[[], None]) -> None:
30
+ """Register a cleanup handler to be called on shutdown.
31
+
32
+ Args:
33
+ handler: Function to call during cleanup
34
+ """
35
+ if handler not in _cleanup_handlers:
36
+ _cleanup_handlers.append(handler)
37
+
38
+
39
+ def unregister_cleanup(handler: Callable[[], None]) -> None:
40
+ """Unregister a cleanup handler.
41
+
42
+ Args:
43
+ handler: Function to remove from cleanup list
44
+ """
45
+ if handler in _cleanup_handlers:
46
+ _cleanup_handlers.remove(handler)
47
+
48
+
49
+ def _run_cleanup() -> None:
50
+ """Run all registered cleanup handlers."""
51
+ for handler in reversed(_cleanup_handlers):
52
+ try:
53
+ handler()
54
+ except Exception as e:
55
+ # Don't let cleanup errors prevent other cleanups
56
+ console.print(f"[dim]Cleanup warning: {e}[/dim]")
57
+
58
+
59
+ def _shutdown_handler(signum: int, frame) -> None:
60
+ """Handle shutdown signals gracefully.
61
+
62
+ Args:
63
+ signum: Signal number received
64
+ frame: Current stack frame
65
+ """
66
+ global _shutdown_in_progress
67
+
68
+ if _shutdown_in_progress:
69
+ # Force exit if already shutting down (user pressed Ctrl+C twice)
70
+ console.print("\n[red]Force shutdown...[/red]")
71
+ sys.exit(1)
72
+
73
+ _shutdown_in_progress = True
74
+ signal_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum)
75
+ console.print(f"\n[yellow]⏳ Received {signal_name}, shutting down gracefully...[/yellow]")
76
+
77
+ _run_cleanup()
78
+
79
+ console.print("[green]✓ Shutdown complete[/green]")
80
+ sys.exit(0)
81
+
82
+
83
+ def _setup_signal_handlers() -> None:
84
+ """Set up signal handlers for graceful shutdown."""
85
+ # Handle SIGINT (Ctrl+C) and SIGTERM
86
+ signal.signal(signal.SIGINT, _shutdown_handler)
87
+ signal.signal(signal.SIGTERM, _shutdown_handler)
88
+
89
+ # Also register cleanup with atexit for normal exits
90
+ atexit.register(_run_cleanup)
91
+
92
+
93
+ # Initialize signal handlers on module import
94
+ _setup_signal_handlers()
95
+
96
+ app = typer.Typer(
97
+ name="codesage",
98
+ help="Local-first code intelligence CLI with LangChain-powered RAG",
99
+ add_completion=False,
100
+ no_args_is_help=True,
101
+ )
102
+ console = Console()
103
+
104
+
105
+ @app.command()
106
+ def init(
107
+ path: str = typer.Argument(".", help="Project directory to initialize"),
108
+ model: str = typer.Option(
109
+ "qwen2.5-coder:7b",
110
+ "--model", "-m",
111
+ help="Ollama model to use for analysis"
112
+ ),
113
+ embedding_model: str = typer.Option(
114
+ "mxbai-embed-large",
115
+ "--embedding-model", "-e",
116
+ help="Model to use for embeddings (mxbai-embed-large recommended for code)"
117
+ ),
118
+ ) -> None:
119
+ """Initialize CodeSage in a project directory.
120
+
121
+ Creates .codesage/ directory with configuration.
122
+ """
123
+ from codesage.utils.config import initialize_project
124
+
125
+ console.print(f"\n[bold blue]🚀 Initializing CodeSage[/bold blue]\n")
126
+
127
+ try:
128
+ project_path = Path(path).resolve()
129
+ config = initialize_project(project_path, model, embedding_model)
130
+
131
+ console.print(f"[green]✓[/green] Created .codesage directory")
132
+ console.print(f"[green]✓[/green] Configuration saved")
133
+
134
+ console.print(Panel(
135
+ f"""[bold]Project:[/bold] {config.project_name}
136
+ [bold]Model:[/bold] {config.llm.model}
137
+ [bold]Embedding:[/bold] {config.llm.embedding_model}
138
+ [bold]Language:[/bold] {config.language}""",
139
+ title="📋 Configuration",
140
+ border_style="blue",
141
+ ))
142
+
143
+ console.print("\n[bold]Next steps:[/bold]")
144
+ console.print(" 1. Ensure Ollama is running: [cyan]ollama serve[/cyan]")
145
+ console.print(" 2. Pull required models:")
146
+ console.print(f" [cyan]ollama pull {model}[/cyan]")
147
+ console.print(f" [cyan]ollama pull {embedding_model}[/cyan]")
148
+ console.print(" 3. Index your codebase: [cyan]codesage index[/cyan]")
149
+ console.print(" 4. Search for code: [cyan]codesage suggest 'your query'[/cyan]\n")
150
+
151
+ except Exception as e:
152
+ console.print(f"[red]✗[/red] Error: {e}")
153
+ raise typer.Exit(1)
154
+
155
+
156
+ @app.command()
157
+ def index(
158
+ path: str = typer.Argument(".", help="Project directory to index"),
159
+ incremental: bool = typer.Option(
160
+ True,
161
+ "--incremental/--full",
162
+ help="Only index changed files (default) or full reindex"
163
+ ),
164
+ clear: bool = typer.Option(
165
+ False,
166
+ "--clear",
167
+ help="Clear existing index before indexing"
168
+ ),
169
+ ) -> None:
170
+ """Index the codebase for semantic search.
171
+
172
+ Parses code files and generates embeddings.
173
+ """
174
+ from codesage.utils.config import Config
175
+ from codesage.core.indexer import Indexer
176
+
177
+ project_path = Path(path).resolve()
178
+
179
+ try:
180
+ config = Config.load(project_path)
181
+ except FileNotFoundError:
182
+ console.print("[red]✗[/red] Project not initialized.")
183
+ console.print(" Run: [cyan]codesage init[/cyan]")
184
+ raise typer.Exit(1)
185
+
186
+ console.print(f"\n[bold cyan]📂 Indexing {config.project_name}[/bold cyan]\n")
187
+
188
+ if not incremental:
189
+ console.print("[dim]Running full reindex...[/dim]")
190
+
191
+ try:
192
+ indexer = Indexer(config)
193
+
194
+ # Register cleanup handler for graceful shutdown
195
+ def _cleanup_indexer():
196
+ try:
197
+ indexer.db.close()
198
+ except Exception:
199
+ pass
200
+
201
+ register_cleanup(_cleanup_indexer)
202
+
203
+ if clear:
204
+ console.print("[yellow]Clearing existing index...[/yellow]")
205
+ indexer.clear_index()
206
+
207
+ stats = indexer.index_repository(incremental=incremental)
208
+
209
+ # Unregister cleanup since we completed successfully
210
+ unregister_cleanup(_cleanup_indexer)
211
+
212
+ console.print()
213
+ console.print(Panel(
214
+ f"""[bold]Files scanned:[/bold] {stats['files_scanned']}
215
+ [bold]Files indexed:[/bold] {stats['files_indexed']}
216
+ [bold]Files skipped:[/bold] {stats['files_skipped']} (unchanged)
217
+ [bold]Code elements:[/bold] {stats['elements_found']}
218
+ [bold]Errors:[/bold] {stats['errors']}""",
219
+ title="📊 Indexing Complete",
220
+ border_style="green",
221
+ ))
222
+
223
+ if stats['elements_found'] > 0:
224
+ console.print("\n[green]✓[/green] Ready for suggestions!")
225
+ console.print(" Try: [cyan]codesage suggest 'your query'[/cyan]\n")
226
+ else:
227
+ console.print("\n[yellow]⚠[/yellow] No code elements found.")
228
+ console.print(" Check that you have Python files in your project.\n")
229
+
230
+ except KeyboardInterrupt:
231
+ console.print("\n[yellow]⚠[/yellow] Indexing interrupted by user.")
232
+ raise typer.Exit(130) # Standard exit code for SIGINT
233
+ except Exception as e:
234
+ console.print(f"[red]✗[/red] Error: {e}")
235
+ raise typer.Exit(1)
236
+
237
+
238
+ @app.command()
239
+ def suggest(
240
+ query: str = typer.Argument(..., help="What are you looking for?"),
241
+ path: str = typer.Option(".", "--path", "-p", help="Project directory"),
242
+ limit: int = typer.Option(5, "--limit", "-n", help="Number of suggestions"),
243
+ min_similarity: float = typer.Option(
244
+ 0.2,
245
+ "--min-similarity", "-s",
246
+ help="Minimum similarity threshold (0-1)"
247
+ ),
248
+ no_explain: bool = typer.Option(
249
+ False,
250
+ "--no-explain",
251
+ help="Skip LLM explanations (faster)"
252
+ ),
253
+ ) -> None:
254
+ """Get code suggestions based on natural language query.
255
+
256
+ Uses semantic search to find relevant code.
257
+ """
258
+ from codesage.utils.config import Config
259
+ from codesage.core.suggester import Suggester
260
+
261
+ # Validate query
262
+ query = query.strip()
263
+ if not query:
264
+ console.print("[red]✗[/red] Query cannot be empty")
265
+ raise typer.Exit(1)
266
+ if len(query) > MAX_QUERY_LENGTH:
267
+ console.print(f"[red]✗[/red] Query too long (max {MAX_QUERY_LENGTH} chars)")
268
+ raise typer.Exit(1)
269
+
270
+ project_path = Path(path).resolve()
271
+
272
+ try:
273
+ config = Config.load(project_path)
274
+ except FileNotFoundError:
275
+ console.print("[red]✗[/red] Project not initialized.")
276
+ console.print(" Run: [cyan]codesage init[/cyan]")
277
+ raise typer.Exit(1)
278
+
279
+ console.print(f"\n[dim]Searching for:[/dim] {query}\n")
280
+
281
+ try:
282
+ suggester = Suggester(config)
283
+ suggestions = suggester.find_similar(
284
+ query=query,
285
+ limit=limit,
286
+ min_similarity=min_similarity,
287
+ include_explanations=not no_explain,
288
+ )
289
+
290
+ if not suggestions:
291
+ console.print("[yellow]No suggestions found.[/yellow]")
292
+ console.print("\n[dim]Tips:[/dim]")
293
+ console.print(" • Try different search terms")
294
+ console.print(" • Lower --min-similarity threshold")
295
+ console.print(" • Run [cyan]codesage index[/cyan] to update the index\n")
296
+ return
297
+
298
+ for i, suggestion in enumerate(suggestions, 1):
299
+ # Header
300
+ console.print(
301
+ f"[bold blue]{i}. {suggestion.file}:{suggestion.line}[/bold blue]"
302
+ )
303
+ console.print(
304
+ f"[dim]Similarity: {suggestion.similarity:.0%} | "
305
+ f"Type: {suggestion.element_type}"
306
+ f"{' | ' + suggestion.name if suggestion.name else ''}[/dim]"
307
+ )
308
+
309
+ # Code with syntax highlighting
310
+ syntax = Syntax(
311
+ suggestion.code,
312
+ suggestion.language,
313
+ theme="monokai",
314
+ line_numbers=True,
315
+ start_line=suggestion.line,
316
+ word_wrap=True,
317
+ )
318
+ console.print(syntax)
319
+
320
+ # Explanation
321
+ if suggestion.explanation:
322
+ console.print(f"[italic]💡 {suggestion.explanation}[/italic]")
323
+
324
+ console.print()
325
+
326
+ console.print(f"[dim]Found {len(suggestions)} suggestion(s)[/dim]\n")
327
+
328
+ except KeyboardInterrupt:
329
+ console.print("\n[yellow]⚠[/yellow] Search interrupted by user.")
330
+ raise typer.Exit(130)
331
+ except Exception as e:
332
+ console.print(f"[red]✗[/red] Error: {e}")
333
+ raise typer.Exit(1)
334
+
335
+
336
+ @app.command()
337
+ def stats(
338
+ path: str = typer.Argument(".", help="Project directory"),
339
+ ) -> None:
340
+ """Show index statistics."""
341
+ from codesage.utils.config import Config
342
+ from codesage.storage.database import Database
343
+
344
+ project_path = Path(path).resolve()
345
+
346
+ try:
347
+ config = Config.load(project_path)
348
+ except FileNotFoundError:
349
+ console.print("[red]✗[/red] Project not initialized.")
350
+ raise typer.Exit(1)
351
+
352
+ db = Database(config.storage.db_path)
353
+ stats = db.get_stats()
354
+
355
+ console.print(Panel(
356
+ f"""[bold]Project:[/bold] {config.project_name}
357
+ [bold]Files indexed:[/bold] {stats['files']}
358
+ [bold]Code elements:[/bold] {stats['elements']}
359
+ [bold]Last indexed:[/bold] {stats['last_indexed'] or 'Never'}
360
+ [bold]Model:[/bold] {config.llm.model}
361
+ [bold]Embedding:[/bold] {config.llm.embedding_model}""",
362
+ title="📊 CodeSage Statistics",
363
+ border_style="blue",
364
+ ))
365
+
366
+
367
+ @app.command()
368
+ def health(
369
+ path: str = typer.Argument(".", help="Project directory"),
370
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
371
+ ) -> None:
372
+ """Check system health and dependencies.
373
+
374
+ Verifies Ollama, database, and vector store are working.
375
+ """
376
+ from codesage.utils.config import Config
377
+ from codesage.utils.health import check_system_health
378
+ import json
379
+
380
+ project_path = Path(path).resolve()
381
+
382
+ try:
383
+ config = Config.load(project_path)
384
+ except FileNotFoundError:
385
+ if json_output:
386
+ console.print(json.dumps({
387
+ "healthy": False,
388
+ "error": "Project not initialized"
389
+ }))
390
+ else:
391
+ console.print("[red]✗[/red] Project not initialized.")
392
+ console.print(" Run: [cyan]codesage init[/cyan]")
393
+ raise typer.Exit(1)
394
+
395
+ status = check_system_health(config)
396
+
397
+ if json_output:
398
+ console.print(json.dumps(status.to_dict(), indent=2))
399
+ else:
400
+ console.print("\n[bold]System Health Check[/bold]\n")
401
+
402
+ # Ollama status
403
+ if status.ollama_available:
404
+ latency = f" ({status.ollama_latency_ms:.0f}ms)" if status.ollama_latency_ms else ""
405
+ console.print(f"[green]✓[/green] Ollama{latency}")
406
+ else:
407
+ console.print("[red]✗[/red] Ollama")
408
+
409
+ # Database status
410
+ if status.database_accessible:
411
+ size = f" ({status.database_size_mb:.1f}MB)" if status.database_size_mb else ""
412
+ console.print(f"[green]✓[/green] Database{size}")
413
+ else:
414
+ console.print("[red]✗[/red] Database")
415
+
416
+ # Vector store status
417
+ if status.vector_store_accessible:
418
+ count = f" ({status.vector_count} vectors)" if status.vector_count else ""
419
+ console.print(f"[green]✓[/green] Vector Store{count}")
420
+ else:
421
+ console.print("[red]✗[/red] Vector Store")
422
+
423
+ # Disk space
424
+ if status.disk_space_ok:
425
+ console.print("[green]✓[/green] Disk Space")
426
+ else:
427
+ console.print("[yellow]⚠[/yellow] Disk Space")
428
+
429
+ # Errors and warnings
430
+ if status.errors:
431
+ console.print("\n[red]Errors:[/red]")
432
+ for error in status.errors:
433
+ console.print(f" • {error}")
434
+
435
+ if status.warnings:
436
+ console.print("\n[yellow]Warnings:[/yellow]")
437
+ for warning in status.warnings:
438
+ console.print(f" • {warning}")
439
+
440
+ # Summary
441
+ console.print()
442
+ if status.is_healthy:
443
+ console.print("[green]✓ System is healthy[/green]")
444
+ else:
445
+ console.print("[red]✗ System has issues[/red]")
446
+ raise typer.Exit(1)
447
+
448
+
449
+ @app.command()
450
+ def version() -> None:
451
+ """Show CodeSage version."""
452
+ console.print(f"[bold]CodeSage[/bold] version {__version__}")
453
+
454
+
455
+ def main() -> None:
456
+ """Main entry point."""
457
+ app()
458
+
459
+
460
+ if __name__ == "__main__":
461
+ main()
@@ -0,0 +1,6 @@
1
+ """Core package exports."""
2
+
3
+ from codesage.core.indexer import Indexer
4
+ from codesage.core.suggester import Suggester
5
+
6
+ __all__ = ["Indexer", "Suggester"]