cognify-code 0.2.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.
Files changed (55) hide show
  1. ai_code_assistant/__init__.py +14 -0
  2. ai_code_assistant/agent/__init__.py +63 -0
  3. ai_code_assistant/agent/code_agent.py +461 -0
  4. ai_code_assistant/agent/code_generator.py +388 -0
  5. ai_code_assistant/agent/code_reviewer.py +365 -0
  6. ai_code_assistant/agent/diff_engine.py +308 -0
  7. ai_code_assistant/agent/file_manager.py +300 -0
  8. ai_code_assistant/agent/intent_classifier.py +284 -0
  9. ai_code_assistant/chat/__init__.py +11 -0
  10. ai_code_assistant/chat/agent_session.py +156 -0
  11. ai_code_assistant/chat/session.py +165 -0
  12. ai_code_assistant/cli.py +1571 -0
  13. ai_code_assistant/config.py +149 -0
  14. ai_code_assistant/editor/__init__.py +8 -0
  15. ai_code_assistant/editor/diff_handler.py +270 -0
  16. ai_code_assistant/editor/file_editor.py +350 -0
  17. ai_code_assistant/editor/prompts.py +146 -0
  18. ai_code_assistant/generator/__init__.py +7 -0
  19. ai_code_assistant/generator/code_gen.py +265 -0
  20. ai_code_assistant/generator/prompts.py +114 -0
  21. ai_code_assistant/git/__init__.py +6 -0
  22. ai_code_assistant/git/commit_generator.py +130 -0
  23. ai_code_assistant/git/manager.py +203 -0
  24. ai_code_assistant/llm.py +111 -0
  25. ai_code_assistant/providers/__init__.py +23 -0
  26. ai_code_assistant/providers/base.py +124 -0
  27. ai_code_assistant/providers/cerebras.py +97 -0
  28. ai_code_assistant/providers/factory.py +148 -0
  29. ai_code_assistant/providers/google.py +103 -0
  30. ai_code_assistant/providers/groq.py +111 -0
  31. ai_code_assistant/providers/ollama.py +86 -0
  32. ai_code_assistant/providers/openai.py +114 -0
  33. ai_code_assistant/providers/openrouter.py +130 -0
  34. ai_code_assistant/py.typed +0 -0
  35. ai_code_assistant/refactor/__init__.py +20 -0
  36. ai_code_assistant/refactor/analyzer.py +189 -0
  37. ai_code_assistant/refactor/change_plan.py +172 -0
  38. ai_code_assistant/refactor/multi_file_editor.py +346 -0
  39. ai_code_assistant/refactor/prompts.py +175 -0
  40. ai_code_assistant/retrieval/__init__.py +19 -0
  41. ai_code_assistant/retrieval/chunker.py +215 -0
  42. ai_code_assistant/retrieval/indexer.py +236 -0
  43. ai_code_assistant/retrieval/search.py +239 -0
  44. ai_code_assistant/reviewer/__init__.py +7 -0
  45. ai_code_assistant/reviewer/analyzer.py +278 -0
  46. ai_code_assistant/reviewer/prompts.py +113 -0
  47. ai_code_assistant/utils/__init__.py +18 -0
  48. ai_code_assistant/utils/file_handler.py +155 -0
  49. ai_code_assistant/utils/formatters.py +259 -0
  50. cognify_code-0.2.0.dist-info/METADATA +383 -0
  51. cognify_code-0.2.0.dist-info/RECORD +55 -0
  52. cognify_code-0.2.0.dist-info/WHEEL +5 -0
  53. cognify_code-0.2.0.dist-info/entry_points.txt +3 -0
  54. cognify_code-0.2.0.dist-info/licenses/LICENSE +22 -0
  55. cognify_code-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1571 @@
1
+ """Command-line interface for AI Code Assistant."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List, Optional, Tuple
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ from ai_code_assistant import __version__
13
+ from ai_code_assistant.config import Config, load_config, get_language_by_extension
14
+ from ai_code_assistant.llm import LLMManager
15
+ from ai_code_assistant.reviewer import CodeAnalyzer
16
+ from ai_code_assistant.generator import CodeGenerator
17
+ from ai_code_assistant.chat import ChatSession
18
+ from ai_code_assistant.editor import FileEditor
19
+ from ai_code_assistant.utils import FileHandler, get_formatter
20
+
21
+ console = Console()
22
+
23
+
24
+ def get_components(config_path: Optional[Path] = None):
25
+ """Initialize and return all components."""
26
+ config = load_config(config_path)
27
+ llm = LLMManager(config)
28
+ return config, llm
29
+
30
+
31
+ @click.group()
32
+ @click.version_option(version=__version__, prog_name="ai-assist")
33
+ @click.option("--config", "-c", type=click.Path(exists=True, path_type=Path), help="Config file path")
34
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
35
+ @click.pass_context
36
+ def main(ctx, config: Optional[Path], verbose: bool):
37
+ """AI Code Assistant - Review and generate code using local LLMs."""
38
+ ctx.ensure_object(dict)
39
+ ctx.obj["config_path"] = config
40
+ ctx.obj["verbose"] = verbose
41
+
42
+
43
+ @main.command()
44
+ @click.argument("files", nargs=-1, type=click.Path(exists=True, path_type=Path))
45
+ @click.option("--type", "-t", "review_type", default="full",
46
+ type=click.Choice(["full", "quick", "security"]), help="Review type")
47
+ @click.option("--format", "-f", "output_format", default="console",
48
+ type=click.Choice(["console", "markdown", "json"]), help="Output format")
49
+ @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output file path")
50
+ @click.option("--recursive", "-r", is_flag=True, help="Recursively review directories")
51
+ @click.pass_context
52
+ def review(ctx, files: Tuple[Path, ...], review_type: str, output_format: str,
53
+ output: Optional[Path], recursive: bool):
54
+ """Review code files for issues and improvements."""
55
+ if not files:
56
+ console.print("[red]Error:[/red] No files specified")
57
+ sys.exit(1)
58
+
59
+ config, llm = get_components(ctx.obj.get("config_path"))
60
+ analyzer = CodeAnalyzer(config, llm)
61
+ file_handler = FileHandler(config)
62
+ formatter = get_formatter(output_format, config.output.use_colors)
63
+
64
+ # Collect all files to review
65
+ all_files = []
66
+ for file_path in files:
67
+ if file_path.is_dir():
68
+ all_files.extend(file_handler.find_code_files(file_path, recursive=recursive))
69
+ else:
70
+ all_files.append(file_path)
71
+
72
+ if not all_files:
73
+ console.print("[yellow]No code files found to review[/yellow]")
74
+ return
75
+
76
+ console.print(f"\n[bold]Reviewing {len(all_files)} file(s)...[/bold]\n")
77
+
78
+ all_output = []
79
+
80
+ with Progress(
81
+ SpinnerColumn(),
82
+ TextColumn("[progress.description]{task.description}"),
83
+ console=console,
84
+ ) as progress:
85
+ task = progress.add_task("Analyzing...", total=len(all_files))
86
+
87
+ for file_path in all_files:
88
+ progress.update(task, description=f"Reviewing {file_path.name}...")
89
+
90
+ result = analyzer.review_file(file_path, review_type=review_type)
91
+ formatted = formatter.format_review(result)
92
+ all_output.append(formatted)
93
+
94
+ progress.advance(task)
95
+
96
+ # Save or display output
97
+ if output:
98
+ combined = "\n\n---\n\n".join(all_output) if output_format != "json" else all_output
99
+ if output_format == "json":
100
+ import json
101
+ combined = json.dumps([json.loads(o) for o in all_output], indent=2)
102
+ output.write_text(combined)
103
+ console.print(f"\n[green]Report saved to:[/green] {output}")
104
+
105
+
106
+ @main.command()
107
+ @click.argument("description")
108
+ @click.option("--mode", "-m", default="generic",
109
+ type=click.Choice(["function", "class", "script", "test", "generic"]))
110
+ @click.option("--language", "-l", default="python", help="Target language")
111
+ @click.option("--name", "-n", help="Name for function/class")
112
+ @click.option("--params", "-p", help="Parameters (for function mode)")
113
+ @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output file")
114
+ @click.option("--format", "-f", "output_format", default="console",
115
+ type=click.Choice(["console", "markdown", "json"]))
116
+ @click.option("--source", "-s", type=click.Path(exists=True, path_type=Path),
117
+ help="Source file (for test mode)")
118
+ @click.pass_context
119
+ def generate(ctx, description: str, mode: str, language: str, name: Optional[str],
120
+ params: Optional[str], output: Optional[Path], output_format: str,
121
+ source: Optional[Path]):
122
+ """Generate code from natural language description."""
123
+ config, llm = get_components(ctx.obj.get("config_path"))
124
+ generator = CodeGenerator(config, llm)
125
+ formatter = get_formatter(output_format, config.output.use_colors)
126
+
127
+ console.print(f"\n[bold]Generating {mode} in {language}...[/bold]\n")
128
+
129
+ with console.status("[bold green]Generating code..."):
130
+ if mode == "function":
131
+ result = generator.generate_function(
132
+ description=description, name=name or "generated_function",
133
+ language=language, parameters=params or "",
134
+ )
135
+ elif mode == "class":
136
+ result = generator.generate_class(
137
+ description=description, name=name or "GeneratedClass", language=language,
138
+ )
139
+ elif mode == "script":
140
+ result = generator.generate_script(
141
+ description=description, requirements=[description], language=language,
142
+ )
143
+ elif mode == "test":
144
+ if not source:
145
+ console.print("[red]Error:[/red] --source required for test mode")
146
+ sys.exit(1)
147
+ source_code = source.read_text()
148
+ result = generator.generate_tests(source_code=source_code, language=language)
149
+ else:
150
+ result = generator.generate(description=description, language=language)
151
+
152
+ formatted = formatter.format_generation(result)
153
+
154
+ if output and result.success:
155
+ output.parent.mkdir(parents=True, exist_ok=True)
156
+ output.write_text(result.code)
157
+ console.print(f"\n[green]Code saved to:[/green] {output}")
158
+
159
+
160
+ @main.command()
161
+ @click.option("--context", "-c", multiple=True, type=click.Path(exists=True, path_type=Path),
162
+ help="Files to load as context")
163
+ @click.option("--stream/--no-stream", default=True, help="Stream responses")
164
+ @click.pass_context
165
+ def chat(ctx, context: Tuple[Path, ...], stream: bool):
166
+ """Start an interactive chat session about code."""
167
+ config, llm = get_components(ctx.obj.get("config_path"))
168
+ session = ChatSession(config, llm)
169
+
170
+ # Load context files
171
+ for file_path in context:
172
+ if session.load_file_context(file_path):
173
+ console.print(f"[dim]Loaded context: {file_path}[/dim]")
174
+ else:
175
+ console.print(f"[yellow]Warning: Could not load {file_path}[/yellow]")
176
+
177
+ console.print(Panel(
178
+ "[bold]AI Code Assistant Chat[/bold]\n\n"
179
+ "Commands:\n"
180
+ " /load <file> - Load a file as context\n"
181
+ " /clear - Clear conversation history\n"
182
+ " /context - Show loaded context files\n"
183
+ " /export - Export conversation to markdown\n"
184
+ " /quit - Exit chat\n",
185
+ title="Interactive Mode",
186
+ ))
187
+
188
+ while True:
189
+ try:
190
+ user_input = console.input("\n[bold cyan]You>[/bold cyan] ").strip()
191
+ except (KeyboardInterrupt, EOFError):
192
+ console.print("\n[dim]Goodbye![/dim]")
193
+ break
194
+
195
+ if not user_input:
196
+ continue
197
+
198
+ # Handle commands
199
+ if user_input.startswith("/"):
200
+ cmd_parts = user_input[1:].split(maxsplit=1)
201
+ cmd = cmd_parts[0].lower()
202
+ arg = cmd_parts[1] if len(cmd_parts) > 1 else ""
203
+
204
+ if cmd == "quit" or cmd == "exit":
205
+ console.print("[dim]Goodbye![/dim]")
206
+ break
207
+ elif cmd == "clear":
208
+ session.clear_history()
209
+ console.print("[dim]History cleared[/dim]")
210
+ elif cmd == "context":
211
+ if session._code_context:
212
+ for name in session._code_context:
213
+ console.print(f" • {name}")
214
+ else:
215
+ console.print("[dim]No context files loaded[/dim]")
216
+ elif cmd == "load" and arg:
217
+ path = Path(arg)
218
+ if session.load_file_context(path):
219
+ console.print(f"[green]Loaded: {path}[/green]")
220
+ else:
221
+ console.print(f"[red]Could not load: {path}[/red]")
222
+ elif cmd == "export":
223
+ export_path = Path("chat_export.md")
224
+ export_path.write_text(session.export_history())
225
+ console.print(f"[green]Exported to: {export_path}[/green]")
226
+ else:
227
+ console.print(f"[yellow]Unknown command: {cmd}[/yellow]")
228
+ continue
229
+
230
+ # Send message
231
+ console.print("\n[bold green]Assistant>[/bold green] ", end="")
232
+
233
+ if stream:
234
+ for chunk in session.send_message(user_input, stream=True):
235
+ console.print(chunk, end="")
236
+ console.print()
237
+ else:
238
+ response = session.send_message(user_input, stream=False)
239
+ console.print(response)
240
+
241
+
242
+ @main.command()
243
+ @click.pass_context
244
+ def status(ctx):
245
+ """Check Ollama connection and model status."""
246
+ config, llm = get_components(ctx.obj.get("config_path"))
247
+
248
+ console.print("\n[bold]AI Code Assistant Status[/bold]\n")
249
+
250
+ # Model info
251
+ info = llm.get_model_info()
252
+ console.print(f"Model: [cyan]{info['model']}[/cyan]")
253
+ console.print(f"Server: [cyan]{info['base_url']}[/cyan]")
254
+ console.print(f"Temperature: {info['temperature']}")
255
+ console.print(f"Max tokens: {info['max_tokens']}")
256
+
257
+ # Connection check
258
+ console.print("\n[bold]Connection Test:[/bold]")
259
+ with console.status("[bold yellow]Testing connection to Ollama..."):
260
+ if llm.check_connection():
261
+ console.print("[green]✓ Ollama is accessible and model is loaded[/green]")
262
+ else:
263
+ console.print("[red]✗ Could not connect to Ollama[/red]")
264
+ console.print("\nMake sure Ollama is running:")
265
+ console.print(" 1. Install Ollama: https://ollama.ai")
266
+ console.print(f" 2. Pull the model: ollama pull {info['model']}")
267
+ console.print(" 3. Start Ollama: ollama serve")
268
+
269
+ # Index status
270
+ console.print("\n[bold]Codebase Index:[/bold]")
271
+ try:
272
+ from ai_code_assistant.retrieval import CodebaseSearch
273
+ search = CodebaseSearch(root_path=str(Path.cwd()))
274
+ count = search._collection.count()
275
+ console.print(f"[green]✓ Index found with {count} chunks[/green]")
276
+ except FileNotFoundError:
277
+ console.print("[yellow]○ No index found. Run 'ai-assist index' to create one.[/yellow]")
278
+ except Exception as e:
279
+ console.print(f"[red]✗ Index error: {e}[/red]")
280
+
281
+
282
+ @main.command()
283
+ @click.argument("directory", default=".", type=click.Path(exists=True, path_type=Path))
284
+ @click.option("--clear", is_flag=True, help="Clear existing index before indexing")
285
+ @click.pass_context
286
+ def index(ctx, directory: Path, clear: bool):
287
+ """Index codebase for semantic search.
288
+
289
+ This creates a searchable index of your code that enables
290
+ natural language queries to find relevant code.
291
+
292
+ Example:
293
+ ai-assist index .
294
+ ai-assist index ./src --clear
295
+ """
296
+ from ai_code_assistant.retrieval import CodebaseIndexer
297
+ from ai_code_assistant.retrieval.indexer import IndexConfig
298
+
299
+ config = load_config(ctx.obj.get("config_path"))
300
+
301
+ # Create indexer config from app config
302
+ index_config = IndexConfig(
303
+ embedding_model=config.retrieval.embedding_model,
304
+ persist_directory=config.retrieval.persist_directory,
305
+ collection_name=config.retrieval.collection_name,
306
+ )
307
+
308
+ console.print(f"\n[bold]Indexing codebase: {directory.absolute()}[/bold]\n")
309
+ console.print(f"Embedding model: [cyan]{index_config.embedding_model}[/cyan]")
310
+ console.print(f"Index location: [cyan]{index_config.persist_directory}[/cyan]\n")
311
+
312
+ indexer = CodebaseIndexer(config=index_config, root_path=str(directory.absolute()))
313
+
314
+ if clear:
315
+ console.print("[yellow]Clearing existing index...[/yellow]")
316
+ indexer.clear_index()
317
+
318
+ console.print("[bold green]Starting indexing...[/bold green]\n")
319
+
320
+ # First-time model download might take a while
321
+ console.print("[dim]Note: First run downloads the embedding model (~90MB)[/dim]\n")
322
+
323
+ stats = indexer.index_directory(verbose=True)
324
+
325
+ console.print(f"\n[bold green]✓ Indexing complete![/bold green]")
326
+ console.print(f" Files indexed: {len(stats.indexed_files)}")
327
+ console.print(f" Total chunks: {stats.total_chunks}")
328
+ if stats.skipped_files:
329
+ console.print(f" Files skipped: {len(stats.skipped_files)}")
330
+ if stats.errors:
331
+ console.print(f" [yellow]Errors: {len(stats.errors)}[/yellow]")
332
+
333
+
334
+ @main.command()
335
+ @click.argument("query")
336
+ @click.option("--top-k", "-k", default=5, help="Number of results to return")
337
+ @click.option("--file", "-f", "file_filter", help="Filter by file path")
338
+ @click.option("--language", "-l", "lang_filter", help="Filter by language")
339
+ @click.option("--format", "output_format", default="console",
340
+ type=click.Choice(["console", "json", "context"]))
341
+ @click.pass_context
342
+ def search(ctx, query: str, top_k: int, file_filter: Optional[str],
343
+ lang_filter: Optional[str], output_format: str):
344
+ """Search codebase using natural language.
345
+
346
+ Find relevant code by describing what you're looking for.
347
+
348
+ Examples:
349
+ ai-assist search "user authentication"
350
+ ai-assist search "database connection" -k 10
351
+ ai-assist search "error handling" --language python
352
+ ai-assist search "config loading" --format context
353
+ """
354
+ from ai_code_assistant.retrieval import CodebaseSearch
355
+ from ai_code_assistant.retrieval.indexer import IndexConfig
356
+
357
+ config = load_config(ctx.obj.get("config_path"))
358
+
359
+ index_config = IndexConfig(
360
+ embedding_model=config.retrieval.embedding_model,
361
+ persist_directory=config.retrieval.persist_directory,
362
+ collection_name=config.retrieval.collection_name,
363
+ )
364
+
365
+ try:
366
+ searcher = CodebaseSearch(config=index_config, root_path=str(Path.cwd()))
367
+ except FileNotFoundError as e:
368
+ console.print(f"[red]Error:[/red] {e}")
369
+ console.print("\nRun [cyan]ai-assist index .[/cyan] first to create the index.")
370
+ sys.exit(1)
371
+
372
+ with console.status("[bold green]Searching..."):
373
+ response = searcher.search(
374
+ query=query,
375
+ top_k=top_k,
376
+ file_filter=file_filter,
377
+ language_filter=lang_filter,
378
+ )
379
+
380
+ if output_format == "json":
381
+ import json
382
+ output = {
383
+ "query": response.query,
384
+ "total_results": response.total_results,
385
+ "results": [r.to_dict() for r in response.results],
386
+ }
387
+ console.print(json.dumps(output, indent=2))
388
+
389
+ elif output_format == "context":
390
+ # Format for use as LLM context
391
+ console.print(response.format_for_llm(max_results=top_k))
392
+
393
+ else: # console
394
+ console.print(f"\n[bold]Search:[/bold] {query}")
395
+ console.print(f"[bold]Results:[/bold] {response.total_results}\n")
396
+
397
+ if not response.has_results:
398
+ console.print("[yellow]No results found.[/yellow]")
399
+ console.print("\nTips:")
400
+ console.print(" • Try broader search terms")
401
+ console.print(" • Check if the codebase is indexed: ai-assist status")
402
+ return
403
+
404
+ for i, result in enumerate(response.results, 1):
405
+ console.print(f"[bold cyan]─── Result {i} ───[/bold cyan]")
406
+ console.print(f"[bold]{result.file_path}[/bold]:{result.start_line}-{result.end_line}")
407
+ console.print(f"[dim]Type: {result.chunk_type} | Name: {result.name} | Score: {result.score:.3f}[/dim]")
408
+ console.print()
409
+
410
+ # Show code with syntax highlighting
411
+ from rich.syntax import Syntax
412
+ syntax = Syntax(
413
+ result.content,
414
+ result.language or "text",
415
+ line_numbers=True,
416
+ start_line=result.start_line,
417
+ theme="monokai",
418
+ )
419
+ console.print(syntax)
420
+ console.print()
421
+
422
+
423
+ @main.command()
424
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
425
+ @click.argument("instruction")
426
+ @click.option("--mode", "-m", default="edit",
427
+ type=click.Choice(["edit", "refactor", "fix", "add"]),
428
+ help="Edit mode")
429
+ @click.option("--preview", "-p", is_flag=True, help="Preview changes without applying")
430
+ @click.option("--no-backup", is_flag=True, help="Don't create backup file")
431
+ @click.option("--format", "-f", "output_format", default="console",
432
+ type=click.Choice(["console", "json"]), help="Output format")
433
+ @click.option("--start-line", "-s", type=int, help="Start line for targeted edit")
434
+ @click.option("--end-line", "-e", type=int, help="End line for targeted edit")
435
+ @click.pass_context
436
+ def edit(ctx, file: Path, instruction: str, mode: str, preview: bool,
437
+ no_backup: bool, output_format: str, start_line: Optional[int],
438
+ end_line: Optional[int]):
439
+ """Edit a file using AI based on natural language instructions.
440
+
441
+ Examples:
442
+ ai-assist edit main.py "Add error handling to the parse function"
443
+ ai-assist edit utils.py "Add type hints" --mode refactor
444
+ ai-assist edit app.py "Fix the null pointer bug" --mode fix
445
+ ai-assist edit api.py "Add logging" --preview
446
+ ai-assist edit config.py "Update the timeout value" -s 10 -e 20
447
+ """
448
+ config, llm = get_components(ctx.obj.get("config_path"))
449
+ editor = FileEditor(config, llm)
450
+
451
+ # Determine edit mode
452
+ edit_mode = mode
453
+ if start_line and end_line:
454
+ edit_mode = "targeted"
455
+
456
+ console.print(f"\n[bold]Editing {file}...[/bold]")
457
+ console.print(f"Mode: [cyan]{edit_mode}[/cyan]")
458
+ console.print(f"Instruction: [dim]{instruction}[/dim]\n")
459
+
460
+ with console.status("[bold green]Generating edit..."):
461
+ result = editor.edit_file(
462
+ file_path=file,
463
+ instruction=instruction,
464
+ mode=edit_mode,
465
+ preview=preview,
466
+ create_backup=not no_backup,
467
+ start_line=start_line,
468
+ end_line=end_line,
469
+ )
470
+
471
+ if output_format == "json":
472
+ import json
473
+ console.print(json.dumps(result.to_dict(), indent=2))
474
+ return
475
+
476
+ # Console output
477
+ if not result.success:
478
+ console.print(f"[red]Error:[/red] {result.error}")
479
+ sys.exit(1)
480
+
481
+ if not result.has_changes:
482
+ console.print("[yellow]No changes detected.[/yellow]")
483
+ return
484
+
485
+ # Show diff
486
+ if result.diff:
487
+ console.print("[bold]Changes:[/bold]")
488
+ console.print(f" [green]+{result.diff.additions}[/green] additions, "
489
+ f"[red]-{result.diff.deletions}[/red] deletions\n")
490
+
491
+ from rich.syntax import Syntax
492
+ diff_text = result.diff.unified_diff
493
+ syntax = Syntax(diff_text, "diff", theme="monokai")
494
+ console.print(syntax)
495
+
496
+ if preview:
497
+ console.print("\n[yellow]Preview mode - changes not applied[/yellow]")
498
+ console.print("Run without --preview to apply changes.")
499
+ else:
500
+ if result.applied:
501
+ console.print(f"\n[green]✓ Changes applied to {file}[/green]")
502
+ if result.backup_path:
503
+ console.print(f"[dim]Backup saved: {result.backup_path}[/dim]")
504
+ else:
505
+ console.print(f"\n[red]✗ Failed to apply changes[/red]")
506
+
507
+
508
+ @main.command()
509
+ @click.argument("instruction")
510
+ @click.option("--files", "-f", multiple=True, type=click.Path(exists=True, path_type=Path),
511
+ help="Specific files to include")
512
+ @click.option("--pattern", "-p", help="Glob pattern to match files (e.g., '**/*.py')")
513
+ @click.option("--directory", "-d", type=click.Path(exists=True, path_type=Path),
514
+ default=".", help="Directory to search for files")
515
+ @click.option("--dry-run", is_flag=True, help="Show plan without applying changes")
516
+ @click.option("--no-confirm", is_flag=True, help="Skip confirmation prompt")
517
+ @click.option("--no-backup", is_flag=True, help="Don't create backup")
518
+ @click.option("--format", "output_format", default="console",
519
+ type=click.Choice(["console", "json"]), help="Output format")
520
+ @click.pass_context
521
+ def refactor(ctx, instruction: str, files: Tuple[Path, ...], pattern: Optional[str],
522
+ directory: Path, dry_run: bool, no_confirm: bool, no_backup: bool,
523
+ output_format: str):
524
+ """Perform multi-file refactoring using AI.
525
+
526
+ Analyzes the codebase and applies coordinated changes across multiple files.
527
+
528
+ Examples:
529
+ ai-assist refactor "Add type hints to all functions"
530
+ ai-assist refactor "Rename User class to Account" -p "**/*.py"
531
+ ai-assist refactor "Extract database logic to repository pattern" --dry-run
532
+ ai-assist refactor "Add logging to all API endpoints" -d ./src/api
533
+ """
534
+ from ai_code_assistant.refactor import MultiFileEditor
535
+ from ai_code_assistant.utils import FileHandler
536
+
537
+ config, llm = get_components(ctx.obj.get("config_path"))
538
+ editor = MultiFileEditor(config, llm)
539
+ file_handler = FileHandler(config)
540
+
541
+ # Collect files to refactor
542
+ all_files: List[Path] = list(files)
543
+
544
+ if pattern:
545
+ # Use glob pattern
546
+ all_files.extend(directory.glob(pattern))
547
+ elif not files:
548
+ # Default: find all code files in directory
549
+ all_files.extend(file_handler.find_code_files(directory, recursive=True))
550
+
551
+ # Remove duplicates and limit
552
+ all_files = list(set(all_files))[:config.refactor.max_files]
553
+
554
+ if not all_files:
555
+ console.print("[red]Error:[/red] No files found to refactor")
556
+ sys.exit(1)
557
+
558
+ console.print(f"\n[bold]Multi-File Refactoring[/bold]")
559
+ console.print(f"Instruction: [cyan]{instruction}[/cyan]")
560
+ console.print(f"Files in scope: [cyan]{len(all_files)}[/cyan]\n")
561
+
562
+ # Show files
563
+ if ctx.obj.get("verbose"):
564
+ for f in all_files[:10]:
565
+ console.print(f" • {f}")
566
+ if len(all_files) > 10:
567
+ console.print(f" ... and {len(all_files) - 10} more")
568
+ console.print()
569
+
570
+ # Analyze and create plan
571
+ with console.status("[bold green]Analyzing codebase..."):
572
+ result = editor.refactor(
573
+ instruction=instruction,
574
+ files=all_files,
575
+ dry_run=True, # Always start with dry run to show plan
576
+ create_backup=not no_backup,
577
+ )
578
+
579
+ if result.error and not result.plan.changes:
580
+ console.print(f"[red]Error:[/red] {result.error}")
581
+ sys.exit(1)
582
+
583
+ # Output as JSON if requested
584
+ if output_format == "json":
585
+ import json
586
+ console.print(json.dumps(result.to_dict(), indent=2))
587
+ return
588
+
589
+ # Show plan
590
+ plan = result.plan
591
+ console.print(f"[bold]Refactoring Plan[/bold]")
592
+ console.print(f"Summary: {plan.summary}")
593
+ console.print(f"Complexity: [cyan]{plan.complexity}[/cyan]")
594
+ console.print(f"Files affected: [cyan]{plan.total_files}[/cyan]\n")
595
+
596
+ if plan.risks:
597
+ console.print("[yellow]Risks:[/yellow]")
598
+ for risk in plan.risks:
599
+ console.print(f" ⚠ {risk}")
600
+ console.print()
601
+
602
+ # Show changes
603
+ console.print("[bold]Planned Changes:[/bold]")
604
+ for change in plan.changes:
605
+ icon = {"modify": "📝", "create": "✨", "delete": "🗑️", "rename": "📛"}.get(
606
+ change.change_type.value, "•"
607
+ )
608
+ console.print(f" {icon} [{change.change_type.value}] {change.file_path}")
609
+ console.print(f" {change.description}")
610
+
611
+ if dry_run:
612
+ console.print("\n[yellow]Dry run - no changes applied[/yellow]")
613
+ return
614
+
615
+ # Confirm before applying
616
+ if not no_confirm and config.refactor.require_confirmation:
617
+ console.print()
618
+ if not click.confirm("Apply these changes?"):
619
+ console.print("[dim]Cancelled[/dim]")
620
+ return
621
+
622
+ # Apply changes
623
+ console.print("\n[bold green]Applying changes...[/bold green]")
624
+
625
+ with console.status("[bold green]Generating and applying changes..."):
626
+ result = editor.refactor(
627
+ instruction=instruction,
628
+ files=all_files,
629
+ dry_run=False,
630
+ create_backup=not no_backup,
631
+ )
632
+
633
+ if result.error:
634
+ console.print(f"[red]Error:[/red] {result.error}")
635
+ sys.exit(1)
636
+
637
+ # Show results
638
+ console.print(f"\n[bold green]✓ Refactoring complete![/bold green]")
639
+ console.print(f" Files changed: {result.files_changed}")
640
+ console.print(f" Additions: [green]+{result.total_additions}[/green]")
641
+ console.print(f" Deletions: [red]-{result.total_deletions}[/red]")
642
+
643
+ if result.backup_dir:
644
+ console.print(f"\n[dim]Backup saved: {result.backup_dir}[/dim]")
645
+
646
+ if result.files_failed > 0:
647
+ console.print(f"\n[yellow]Warning: {result.files_failed} file(s) failed[/yellow]")
648
+ for change in result.plan.changes:
649
+ if change.error:
650
+ console.print(f" • {change.file_path}: {change.error}")
651
+
652
+
653
+ @main.command()
654
+ @click.argument("old_name")
655
+ @click.argument("new_name")
656
+ @click.option("--type", "-t", "symbol_type", default="symbol",
657
+ type=click.Choice(["function", "class", "variable", "method", "symbol"]),
658
+ help="Type of symbol to rename")
659
+ @click.option("--files", "-f", multiple=True, type=click.Path(exists=True, path_type=Path),
660
+ help="Specific files to include")
661
+ @click.option("--pattern", "-p", help="Glob pattern to match files")
662
+ @click.option("--directory", "-d", type=click.Path(exists=True, path_type=Path),
663
+ default=".", help="Directory to search")
664
+ @click.option("--dry-run", is_flag=True, help="Show changes without applying")
665
+ @click.pass_context
666
+ def rename(ctx, old_name: str, new_name: str, symbol_type: str, files: Tuple[Path, ...],
667
+ pattern: Optional[str], directory: Path, dry_run: bool):
668
+ """Rename a symbol across multiple files.
669
+
670
+ Examples:
671
+ ai-assist rename UserService AccountService --type class
672
+ ai-assist rename get_user fetch_user --type function -p "**/*.py"
673
+ ai-assist rename API_KEY API_SECRET --type variable --dry-run
674
+ """
675
+ from ai_code_assistant.refactor import MultiFileEditor
676
+ from ai_code_assistant.utils import FileHandler
677
+
678
+ config, llm = get_components(ctx.obj.get("config_path"))
679
+ editor = MultiFileEditor(config, llm)
680
+ file_handler = FileHandler(config)
681
+
682
+ # Collect files
683
+ all_files: List[Path] = list(files)
684
+ if pattern:
685
+ all_files.extend(directory.glob(pattern))
686
+ elif not files:
687
+ all_files.extend(file_handler.find_code_files(directory, recursive=True))
688
+
689
+ all_files = list(set(all_files))[:config.refactor.max_files]
690
+
691
+ if not all_files:
692
+ console.print("[red]Error:[/red] No files found")
693
+ sys.exit(1)
694
+
695
+ console.print(f"\n[bold]Rename Symbol[/bold]")
696
+ console.print(f"Renaming {symbol_type}: [cyan]{old_name}[/cyan] → [green]{new_name}[/green]")
697
+ console.print(f"Files to search: [cyan]{len(all_files)}[/cyan]\n")
698
+
699
+ with console.status("[bold green]Searching and renaming..."):
700
+ result = editor.rename_symbol(
701
+ old_name=old_name,
702
+ new_name=new_name,
703
+ symbol_type=symbol_type,
704
+ files=all_files,
705
+ dry_run=dry_run,
706
+ )
707
+
708
+ if result.error:
709
+ console.print(f"[red]Error:[/red] {result.error}")
710
+ sys.exit(1)
711
+
712
+ # Show results
713
+ console.print(f"[bold]Files affected: {result.plan.total_files}[/bold]\n")
714
+
715
+ for change in result.plan.changes:
716
+ status = "[green]✓[/green]" if change.applied else "[yellow]○[/yellow]"
717
+ console.print(f" {status} {change.file_path}")
718
+
719
+ if dry_run:
720
+ console.print("\n[yellow]Dry run - no changes applied[/yellow]")
721
+ else:
722
+ console.print(f"\n[green]✓ Renamed {old_name} to {new_name} in {result.files_changed} file(s)[/green]")
723
+
724
+
725
+
726
+ @main.command()
727
+ @click.option("--format", "-f", "output_format", default="console",
728
+ type=click.Choice(["console", "json"]), help="Output format")
729
+ @click.pass_context
730
+ def providers(ctx, output_format: str):
731
+ """List all available LLM providers and their models.
732
+
733
+ Shows provider information including:
734
+ - Whether an API key is required
735
+ - Free tier availability
736
+ - Available models with descriptions
737
+
738
+ Examples:
739
+ ai-assist providers
740
+ ai-assist providers --format json
741
+ """
742
+ from ai_code_assistant.providers.factory import get_available_providers
743
+
744
+ providers_info = get_available_providers()
745
+
746
+ if output_format == "json":
747
+ import json
748
+ console.print(json.dumps(providers_info, indent=2))
749
+ return
750
+
751
+ # Console output
752
+ console.print("\n[bold]Available LLM Providers[/bold]\n")
753
+
754
+ for provider_name, info in providers_info.items():
755
+ # Provider header
756
+ api_badge = "[red]API Key Required[/red]" if info["requires_api_key"] else "[green]No API Key[/green]"
757
+ free_badge = "[green]Free Tier[/green]" if info["free_tier"] else "[yellow]Paid Only[/yellow]"
758
+
759
+ console.print(f"[bold cyan]━━━ {info['display_name']} ({provider_name}) ━━━[/bold cyan]")
760
+ console.print(f" {api_badge} | {free_badge}")
761
+ console.print(f" Default model: [dim]{info['default_model']}[/dim]")
762
+ console.print()
763
+
764
+ # Models table
765
+ console.print(" [bold]Models:[/bold]")
766
+ for model in info["models"]:
767
+ free_icon = "🆓" if model["is_free"] else "💰"
768
+ console.print(f" {free_icon} [cyan]{model['name']}[/cyan]")
769
+ console.print(f" {model['description']}")
770
+ console.print(f" Context: {model['context_window']:,} tokens")
771
+ console.print()
772
+
773
+ # Show current configuration
774
+ config = load_config(ctx.obj.get("config_path"))
775
+ console.print("[bold]Current Configuration:[/bold]")
776
+ console.print(f" Provider: [cyan]{config.llm.provider}[/cyan]")
777
+ console.print(f" Model: [cyan]{config.llm.model}[/cyan]")
778
+
779
+ # Check for API keys
780
+ import os
781
+ console.print("\n[bold]API Key Status:[/bold]")
782
+ env_vars = [
783
+ ("GOOGLE_API_KEY", "Google"),
784
+ ("GROQ_API_KEY", "Groq"),
785
+ ("CEREBRAS_API_KEY", "Cerebras"),
786
+ ("OPENROUTER_API_KEY", "OpenRouter"),
787
+ ("OPENAI_API_KEY", "OpenAI"),
788
+ ]
789
+ for env_var, name in env_vars:
790
+ status = "[green]✓ Set[/green]" if os.getenv(env_var) else "[dim]Not set[/dim]"
791
+ console.print(f" {name}: {status}")
792
+
793
+
794
+ @main.command("use-provider")
795
+ @click.argument("provider", type=click.Choice(["ollama", "google", "groq", "cerebras", "openrouter", "openai"]))
796
+ @click.option("--model", "-m", help="Model to use (uses provider default if not specified)")
797
+ @click.option("--api-key", "-k", help="API key (can also use environment variable)")
798
+ @click.option("--test", "-t", is_flag=True, help="Test the connection after switching")
799
+ @click.pass_context
800
+ def use_provider(ctx, provider: str, model: Optional[str], api_key: Optional[str], test: bool):
801
+ """Switch to a different LLM provider.
802
+
803
+ This updates the current session's provider. To make permanent changes,
804
+ update your config.yaml file.
805
+
806
+ Examples:
807
+ ai-assist use-provider groq
808
+ ai-assist use-provider google --model gemini-1.5-pro
809
+ ai-assist use-provider openrouter --model deepseek/deepseek-r1:free --test
810
+ """
811
+ from ai_code_assistant.providers.factory import PROVIDER_REGISTRY, get_provider
812
+ from ai_code_assistant.providers.base import ProviderConfig, ProviderType
813
+
814
+ config = load_config(ctx.obj.get("config_path"))
815
+
816
+ # Get provider class for default model
817
+ provider_type = ProviderType(provider)
818
+ provider_class = PROVIDER_REGISTRY.get(provider_type)
819
+
820
+ if not provider_class:
821
+ console.print(f"[red]Error:[/red] Unknown provider: {provider}")
822
+ sys.exit(1)
823
+
824
+ # Use default model if not specified
825
+ if not model:
826
+ model = provider_class.default_model
827
+
828
+ console.print(f"\n[bold]Switching to {provider_class.display_name}[/bold]")
829
+ console.print(f" Model: [cyan]{model}[/cyan]")
830
+
831
+ # Check API key
832
+ import os
833
+ env_var_map = {
834
+ "google": "GOOGLE_API_KEY",
835
+ "groq": "GROQ_API_KEY",
836
+ "cerebras": "CEREBRAS_API_KEY",
837
+ "openrouter": "OPENROUTER_API_KEY",
838
+ "openai": "OPENAI_API_KEY",
839
+ }
840
+
841
+ if provider_class.requires_api_key:
842
+ env_var = env_var_map.get(provider)
843
+ env_key = os.getenv(env_var) if env_var else None
844
+
845
+ if not api_key and not env_key:
846
+ console.print(f"\n[yellow]Warning:[/yellow] No API key provided")
847
+ console.print(f"Set {env_var} environment variable or use --api-key")
848
+ console.print(f"\n{provider_class.get_setup_instructions()}")
849
+ sys.exit(1)
850
+
851
+ api_key = api_key or env_key
852
+
853
+ # Create provider config
854
+ provider_config = ProviderConfig(
855
+ provider=provider_type,
856
+ model=model,
857
+ api_key=api_key,
858
+ temperature=config.llm.temperature,
859
+ max_tokens=config.llm.max_tokens,
860
+ timeout=config.llm.timeout,
861
+ )
862
+
863
+ # Validate config
864
+ try:
865
+ provider_instance = get_provider(provider_config)
866
+ is_valid, error = provider_instance.validate_config()
867
+ if not is_valid:
868
+ console.print(f"[red]Configuration error:[/red] {error}")
869
+ sys.exit(1)
870
+ except Exception as e:
871
+ console.print(f"[red]Error creating provider:[/red] {e}")
872
+ sys.exit(1)
873
+
874
+ console.print("[green]✓ Provider configured successfully[/green]")
875
+
876
+ # Test connection if requested
877
+ if test:
878
+ console.print("\n[bold]Testing connection...[/bold]")
879
+ with console.status("[bold green]Sending test request..."):
880
+ try:
881
+ response = provider_instance.invoke("Say 'Hello from {provider}!' and nothing else.")
882
+ console.print(f"[green]✓ Connection successful![/green]")
883
+ console.print(f" Response: [dim]{response.strip()}[/dim]")
884
+ except Exception as e:
885
+ console.print(f"[red]✗ Connection failed:[/red] {e}")
886
+ sys.exit(1)
887
+
888
+ # Show how to make permanent
889
+ console.print("\n[dim]To make this permanent, update your config.yaml:[/dim]")
890
+ console.print(f"[dim] llm:[/dim]")
891
+ console.print(f"[dim] provider: {provider}[/dim]")
892
+ console.print(f"[dim] model: {model}[/dim]")
893
+
894
+
895
+ @main.command("test-provider")
896
+ @click.option("--provider", "-p", help="Provider to test (uses current config if not specified)")
897
+ @click.option("--model", "-m", help="Model to test")
898
+ @click.option("--prompt", default="Write a Python function that adds two numbers.", help="Test prompt")
899
+ @click.pass_context
900
+ def test_provider(ctx, provider: Optional[str], model: Optional[str], prompt: str):
901
+ """Test the current or specified LLM provider.
902
+
903
+ Sends a test prompt and displays the response with timing information.
904
+
905
+ Examples:
906
+ ai-assist test-provider
907
+ ai-assist test-provider --provider groq
908
+ ai-assist test-provider --prompt "Explain recursion in one sentence"
909
+ """
910
+ import time
911
+ from ai_code_assistant.providers.factory import get_provider, PROVIDER_REGISTRY
912
+ from ai_code_assistant.providers.base import ProviderConfig, ProviderType
913
+
914
+ config = load_config(ctx.obj.get("config_path"))
915
+
916
+ # Use current config or specified provider
917
+ if provider:
918
+ provider_type = ProviderType(provider)
919
+ provider_class = PROVIDER_REGISTRY.get(provider_type)
920
+ if not model:
921
+ model = provider_class.default_model
922
+ else:
923
+ provider_type = ProviderType(config.llm.provider)
924
+ model = model or config.llm.model
925
+
926
+ console.print(f"\n[bold]Testing LLM Provider[/bold]")
927
+ console.print(f" Provider: [cyan]{provider_type.value}[/cyan]")
928
+ console.print(f" Model: [cyan]{model}[/cyan]")
929
+ console.print(f" Prompt: [dim]{prompt[:50]}{'...' if len(prompt) > 50 else ''}[/dim]\n")
930
+
931
+ # Create provider
932
+ import os
933
+ env_var_map = {
934
+ "google": "GOOGLE_API_KEY",
935
+ "groq": "GROQ_API_KEY",
936
+ "cerebras": "CEREBRAS_API_KEY",
937
+ "openrouter": "OPENROUTER_API_KEY",
938
+ "openai": "OPENAI_API_KEY",
939
+ }
940
+
941
+ api_key = config.llm.api_key
942
+ if not api_key:
943
+ env_var = env_var_map.get(provider_type.value)
944
+ api_key = os.getenv(env_var) if env_var else None
945
+
946
+ provider_config = ProviderConfig(
947
+ provider=provider_type,
948
+ model=model,
949
+ api_key=api_key,
950
+ base_url=config.llm.base_url if provider_type == ProviderType.OLLAMA else None,
951
+ temperature=config.llm.temperature,
952
+ max_tokens=config.llm.max_tokens,
953
+ timeout=config.llm.timeout,
954
+ )
955
+
956
+ try:
957
+ provider_instance = get_provider(provider_config)
958
+ except Exception as e:
959
+ console.print(f"[red]Error creating provider:[/red] {e}")
960
+ sys.exit(1)
961
+
962
+ # Send test request
963
+ console.print("[bold]Sending request...[/bold]\n")
964
+ start_time = time.time()
965
+
966
+ try:
967
+ response = provider_instance.invoke(prompt)
968
+ elapsed = time.time() - start_time
969
+
970
+ console.print("[bold green]Response:[/bold green]")
971
+ console.print(Panel(response.strip(), border_style="green"))
972
+
973
+ console.print(f"\n[dim]Time: {elapsed:.2f}s[/dim]")
974
+ console.print(f"[dim]Tokens (approx): {len(response.split())} words[/dim]")
975
+
976
+ except Exception as e:
977
+ elapsed = time.time() - start_time
978
+ console.print(f"[red]Error:[/red] {e}")
979
+ console.print(f"[dim]Failed after {elapsed:.2f}s[/dim]")
980
+ sys.exit(1)
981
+
982
+
983
+
984
+ # ============================================================================
985
+ # Git Integration Commands
986
+ # ============================================================================
987
+
988
+ @main.group()
989
+ def git():
990
+ """Git integration commands with AI-powered commit messages."""
991
+ pass
992
+
993
+
994
+ @git.command("status")
995
+ @click.pass_context
996
+ def git_status(ctx):
997
+ """Show git status with summary.
998
+
999
+ Examples:
1000
+ ai-assist git status
1001
+ """
1002
+ from ai_code_assistant.git import GitManager
1003
+
1004
+ try:
1005
+ git_mgr = GitManager()
1006
+ except ValueError as e:
1007
+ console.print(f"[red]Error:[/red] {e}")
1008
+ sys.exit(1)
1009
+
1010
+ status = git_mgr.get_status()
1011
+
1012
+ console.print(f"\n[bold]Git Status[/bold]")
1013
+ console.print(f" Branch: [cyan]{status.branch}[/cyan]")
1014
+
1015
+ if status.ahead:
1016
+ console.print(f" [green]↑ {status.ahead} commit(s) ahead[/green]")
1017
+ if status.behind:
1018
+ console.print(f" [yellow]↓ {status.behind} commit(s) behind[/yellow]")
1019
+
1020
+ if not status.has_changes:
1021
+ console.print("\n[green]✓ Working tree clean[/green]")
1022
+ return
1023
+
1024
+ console.print(f"\n[bold]Changes ({status.total_changes} files):[/bold]")
1025
+
1026
+ if status.staged:
1027
+ console.print(f"\n [green]Staged ({len(status.staged)}):[/green]")
1028
+ for f in status.staged[:10]:
1029
+ console.print(f" [green]✓[/green] {f}")
1030
+ if len(status.staged) > 10:
1031
+ console.print(f" ... and {len(status.staged) - 10} more")
1032
+
1033
+ if status.modified:
1034
+ console.print(f"\n [yellow]Modified ({len(status.modified)}):[/yellow]")
1035
+ for f in status.modified[:10]:
1036
+ console.print(f" [yellow]●[/yellow] {f}")
1037
+ if len(status.modified) > 10:
1038
+ console.print(f" ... and {len(status.modified) - 10} more")
1039
+
1040
+ if status.untracked:
1041
+ console.print(f"\n [dim]Untracked ({len(status.untracked)}):[/dim]")
1042
+ for f in status.untracked[:10]:
1043
+ console.print(f" [dim]?[/dim] {f}")
1044
+ if len(status.untracked) > 10:
1045
+ console.print(f" ... and {len(status.untracked) - 10} more")
1046
+
1047
+ if status.deleted:
1048
+ console.print(f"\n [red]Deleted ({len(status.deleted)}):[/red]")
1049
+ for f in status.deleted[:10]:
1050
+ console.print(f" [red]✗[/red] {f}")
1051
+
1052
+
1053
+ @git.command("commit")
1054
+ @click.option("--message", "-m", help="Commit message (AI generates if not provided)")
1055
+ @click.option("--all", "-a", "stage_all", is_flag=True, help="Stage all changes before commit")
1056
+ @click.option("--push", "-p", "push_after", is_flag=True, help="Push after commit")
1057
+ @click.option("--no-confirm", is_flag=True, help="Skip confirmation prompt")
1058
+ @click.pass_context
1059
+ def git_commit(ctx, message: Optional[str], stage_all: bool, push_after: bool, no_confirm: bool):
1060
+ """Commit changes with AI-generated message.
1061
+
1062
+ If no message is provided, AI analyzes the diff and generates one.
1063
+
1064
+ Examples:
1065
+ ai-assist git commit # AI generates message
1066
+ ai-assist git commit -m "Fix bug" # Use provided message
1067
+ ai-assist git commit -a --push # Stage all, commit, push
1068
+ ai-assist git commit --no-confirm # Skip confirmation
1069
+ """
1070
+ from ai_code_assistant.git import GitManager, CommitMessageGenerator
1071
+
1072
+ config, llm = get_components(ctx.obj.get("config_path"))
1073
+
1074
+ try:
1075
+ git_mgr = GitManager()
1076
+ except ValueError as e:
1077
+ console.print(f"[red]Error:[/red] {e}")
1078
+ sys.exit(1)
1079
+
1080
+ status = git_mgr.get_status()
1081
+
1082
+ # Stage all if requested
1083
+ if stage_all:
1084
+ console.print("[dim]Staging all changes...[/dim]")
1085
+ git_mgr.stage_all()
1086
+ status = git_mgr.get_status()
1087
+
1088
+ # Check if there are staged changes
1089
+ if not status.has_staged:
1090
+ if status.has_changes:
1091
+ console.print("[yellow]No staged changes.[/yellow]")
1092
+ console.print("Use [cyan]--all[/cyan] to stage all changes, or stage manually with [cyan]git add[/cyan]")
1093
+ else:
1094
+ console.print("[yellow]Nothing to commit, working tree clean.[/yellow]")
1095
+ sys.exit(1)
1096
+
1097
+ # Generate or use provided message
1098
+ if not message:
1099
+ console.print("\n[bold]Generating commit message...[/bold]")
1100
+ with console.status("[bold green]Analyzing changes..."):
1101
+ generator = CommitMessageGenerator(llm)
1102
+ message = generator.generate(git_mgr)
1103
+
1104
+ if not message:
1105
+ console.print("[red]Error:[/red] Could not generate commit message")
1106
+ sys.exit(1)
1107
+
1108
+ # Show commit preview
1109
+ diff = git_mgr.get_diff(staged=True)
1110
+ console.print(f"\n[bold]Commit Preview[/bold]")
1111
+ console.print(f" Files: [cyan]{diff.files_changed}[/cyan]")
1112
+ console.print(f" Changes: [green]+{diff.insertions}[/green] / [red]-{diff.deletions}[/red]")
1113
+ console.print(f"\n[bold]Message:[/bold]")
1114
+ console.print(Panel(message, border_style="cyan"))
1115
+
1116
+ # Confirm
1117
+ if not no_confirm:
1118
+ if not click.confirm("\nProceed with commit?"):
1119
+ console.print("[dim]Cancelled[/dim]")
1120
+ return
1121
+
1122
+ # Commit
1123
+ try:
1124
+ commit_hash = git_mgr.commit(message)
1125
+ console.print(f"\n[green]✓ Committed:[/green] {commit_hash}")
1126
+ except Exception as e:
1127
+ console.print(f"[red]Error committing:[/red] {e}")
1128
+ sys.exit(1)
1129
+
1130
+ # Push if requested
1131
+ if push_after:
1132
+ console.print("\n[bold]Pushing to remote...[/bold]")
1133
+ success, output = git_mgr.push()
1134
+ if success:
1135
+ console.print(f"[green]✓ Pushed to {status.remote}/{status.branch}[/green]")
1136
+ else:
1137
+ console.print(f"[red]Error pushing:[/red] {output}")
1138
+ sys.exit(1)
1139
+
1140
+
1141
+ @git.command("push")
1142
+ @click.option("--remote", "-r", default="origin", help="Remote name")
1143
+ @click.option("--branch", "-b", help="Branch name (default: current branch)")
1144
+ @click.option("--set-upstream", "-u", is_flag=True, help="Set upstream for the branch")
1145
+ @click.pass_context
1146
+ def git_push(ctx, remote: str, branch: Optional[str], set_upstream: bool):
1147
+ """Push commits to remote repository.
1148
+
1149
+ Examples:
1150
+ ai-assist git push
1151
+ ai-assist git push --remote origin --branch main
1152
+ ai-assist git push -u # Set upstream
1153
+ """
1154
+ from ai_code_assistant.git import GitManager
1155
+
1156
+ try:
1157
+ git_mgr = GitManager()
1158
+ except ValueError as e:
1159
+ console.print(f"[red]Error:[/red] {e}")
1160
+ sys.exit(1)
1161
+
1162
+ status = git_mgr.get_status()
1163
+ branch = branch or status.branch
1164
+
1165
+ console.print(f"\n[bold]Pushing to {remote}/{branch}...[/bold]")
1166
+
1167
+ with console.status("[bold green]Pushing..."):
1168
+ success, output = git_mgr.push(remote=remote, branch=branch, set_upstream=set_upstream)
1169
+
1170
+ if success:
1171
+ console.print(f"[green]✓ Successfully pushed to {remote}/{branch}[/green]")
1172
+
1173
+ # Show remote URL
1174
+ remote_url = git_mgr.get_remote_url()
1175
+ if "github.com" in remote_url:
1176
+ # Convert SSH to HTTPS URL for display
1177
+ if remote_url.startswith("git@"):
1178
+ remote_url = remote_url.replace("git@github.com:", "https://github.com/").replace(".git", "")
1179
+ console.print(f"\n[dim]View at: {remote_url}[/dim]")
1180
+ else:
1181
+ console.print(f"[red]Error:[/red] Push failed")
1182
+ console.print(f"[dim]{output}[/dim]")
1183
+ sys.exit(1)
1184
+
1185
+
1186
+ @git.command("sync")
1187
+ @click.option("--message", "-m", help="Commit message (AI generates if not provided)")
1188
+ @click.option("--no-confirm", is_flag=True, help="Skip confirmation prompt")
1189
+ @click.pass_context
1190
+ def git_sync(ctx, message: Optional[str], no_confirm: bool):
1191
+ """Stage all changes, commit with AI message, and push.
1192
+
1193
+ This is a convenience command that combines:
1194
+ 1. git add -A
1195
+ 2. git commit (with AI-generated message)
1196
+ 3. git push
1197
+
1198
+ Examples:
1199
+ ai-assist git sync # Full sync with AI message
1200
+ ai-assist git sync -m "Update" # Sync with custom message
1201
+ ai-assist git sync --no-confirm # Skip confirmation
1202
+ """
1203
+ from ai_code_assistant.git import GitManager, CommitMessageGenerator
1204
+
1205
+ config, llm = get_components(ctx.obj.get("config_path"))
1206
+
1207
+ try:
1208
+ git_mgr = GitManager()
1209
+ except ValueError as e:
1210
+ console.print(f"[red]Error:[/red] {e}")
1211
+ sys.exit(1)
1212
+
1213
+ status = git_mgr.get_status()
1214
+
1215
+ if not status.has_changes:
1216
+ console.print("[yellow]Nothing to sync, working tree clean.[/yellow]")
1217
+ return
1218
+
1219
+ console.print(f"\n[bold]Git Sync[/bold]")
1220
+ console.print(f" Branch: [cyan]{status.branch}[/cyan]")
1221
+ console.print(f" Changes: [cyan]{status.total_changes} files[/cyan]")
1222
+
1223
+ # Stage all
1224
+ console.print("\n[dim]Staging all changes...[/dim]")
1225
+ git_mgr.stage_all()
1226
+ status = git_mgr.get_status()
1227
+
1228
+ # Generate message if not provided
1229
+ if not message:
1230
+ console.print("[dim]Generating commit message...[/dim]")
1231
+ with console.status("[bold green]Analyzing changes..."):
1232
+ generator = CommitMessageGenerator(llm)
1233
+ message = generator.generate(git_mgr)
1234
+
1235
+ if not message:
1236
+ console.print("[red]Error:[/red] Could not generate commit message")
1237
+ sys.exit(1)
1238
+
1239
+ # Show preview
1240
+ diff = git_mgr.get_diff(staged=True)
1241
+ console.print(f"\n[bold]Sync Preview[/bold]")
1242
+ console.print(f" Files: [cyan]{diff.files_changed}[/cyan]")
1243
+ console.print(f" Changes: [green]+{diff.insertions}[/green] / [red]-{diff.deletions}[/red]")
1244
+ console.print(f" Push to: [cyan]{status.remote}/{status.branch}[/cyan]")
1245
+ console.print(f"\n[bold]Message:[/bold]")
1246
+ console.print(Panel(message, border_style="cyan"))
1247
+
1248
+ # Confirm
1249
+ if not no_confirm:
1250
+ if not click.confirm("\nProceed with sync?"):
1251
+ console.print("[dim]Cancelled[/dim]")
1252
+ return
1253
+
1254
+ # Commit
1255
+ console.print("\n[dim]Committing...[/dim]")
1256
+ try:
1257
+ commit_hash = git_mgr.commit(message)
1258
+ console.print(f"[green]✓ Committed:[/green] {commit_hash}")
1259
+ except Exception as e:
1260
+ console.print(f"[red]Error committing:[/red] {e}")
1261
+ sys.exit(1)
1262
+
1263
+ # Push
1264
+ console.print("[dim]Pushing...[/dim]")
1265
+ success, output = git_mgr.push()
1266
+ if success:
1267
+ console.print(f"[green]✓ Pushed to {status.remote}/{status.branch}[/green]")
1268
+
1269
+ # Show GitHub URL
1270
+ remote_url = git_mgr.get_remote_url()
1271
+ if "github.com" in remote_url:
1272
+ if remote_url.startswith("git@"):
1273
+ remote_url = remote_url.replace("git@github.com:", "https://github.com/").replace(".git", "")
1274
+ elif remote_url.endswith(".git"):
1275
+ remote_url = remote_url[:-4]
1276
+ console.print(f"\n[bold green]✓ Sync complete![/bold green]")
1277
+ console.print(f"[dim]View at: {remote_url}[/dim]")
1278
+ else:
1279
+ console.print(f"[red]Error pushing:[/red] {output}")
1280
+ sys.exit(1)
1281
+
1282
+
1283
+ @git.command("log")
1284
+ @click.option("--count", "-n", default=10, help="Number of commits to show")
1285
+ @click.pass_context
1286
+ def git_log(ctx, count: int):
1287
+ """Show recent commit history.
1288
+
1289
+ Examples:
1290
+ ai-assist git log
1291
+ ai-assist git log -n 20
1292
+ """
1293
+ from ai_code_assistant.git import GitManager
1294
+
1295
+ try:
1296
+ git_mgr = GitManager()
1297
+ except ValueError as e:
1298
+ console.print(f"[red]Error:[/red] {e}")
1299
+ sys.exit(1)
1300
+
1301
+ commits = git_mgr.get_recent_commits(count=count)
1302
+
1303
+ console.print(f"\n[bold]Recent Commits[/bold]\n")
1304
+
1305
+ for commit in commits:
1306
+ console.print(f"[yellow]{commit['short_hash']}[/yellow] {commit['message']}")
1307
+ console.print(f" [dim]{commit['author']} • {commit['time']}[/dim]")
1308
+
1309
+ # =============================================================================
1310
+ # Agent Commands
1311
+ # =============================================================================
1312
+
1313
+ @main.command()
1314
+ @click.option("--path", "-p", type=click.Path(exists=True, path_type=Path),
1315
+ default=".", help="Project root path")
1316
+ @click.pass_context
1317
+ def agent(ctx, path: Path):
1318
+ """Start interactive AI code agent.
1319
+
1320
+ The agent can:
1321
+ - Generate code based on descriptions
1322
+ - Review files for issues
1323
+ - Edit existing code
1324
+ - Explain how code works
1325
+ - Refactor code
1326
+ - Generate tests
1327
+
1328
+ Examples:
1329
+ ai-assist agent
1330
+ ai-assist agent --path ./my-project
1331
+
1332
+ In agent mode, try commands like:
1333
+ "create a function to validate email addresses"
1334
+ "review src/main.py"
1335
+ "explain how the config module works"
1336
+ "refactor utils.py to use async"
1337
+ "generate tests for src/api.py"
1338
+ """
1339
+ from ai_code_assistant.chat import AgentChatSession
1340
+ from rich.markdown import Markdown
1341
+ from rich.prompt import Prompt
1342
+
1343
+ config, llm = get_components(ctx.obj.get("config_path"))
1344
+
1345
+ # Initialize agent session
1346
+ session = AgentChatSession(config, llm, path.resolve())
1347
+
1348
+ # Show welcome message
1349
+ console.print(Panel.fit(
1350
+ "[bold cyan]🤖 AI Code Agent[/bold cyan]\n\n"
1351
+ "I can help you with:\n"
1352
+ " • [green]Generate[/green] - Create new code\n"
1353
+ " • [yellow]Review[/yellow] - Analyze code for issues\n"
1354
+ " • [blue]Edit[/blue] - Modify existing files\n"
1355
+ " • [magenta]Explain[/magenta] - Understand code\n"
1356
+ " • [cyan]Refactor[/cyan] - Improve code quality\n"
1357
+ " • [white]Test[/white] - Generate unit tests\n\n"
1358
+ "Type [bold]'help'[/bold] for examples, [bold]'quit'[/bold] to exit.",
1359
+ title="Welcome",
1360
+ border_style="cyan",
1361
+ ))
1362
+
1363
+ # Show project info
1364
+ project_info = session.get_project_info()
1365
+ console.print(f"\n{project_info}\n")
1366
+
1367
+ # Main loop
1368
+ while True:
1369
+ try:
1370
+ user_input = Prompt.ask("\n[bold cyan]You[/bold cyan]")
1371
+
1372
+ if not user_input.strip():
1373
+ continue
1374
+
1375
+ # Handle special commands
1376
+ lower_input = user_input.lower().strip()
1377
+
1378
+ if lower_input in ("quit", "exit", "q"):
1379
+ console.print("[dim]Goodbye![/dim]")
1380
+ break
1381
+
1382
+ if lower_input == "help":
1383
+ _show_agent_help()
1384
+ continue
1385
+
1386
+ if lower_input == "clear":
1387
+ session.clear_history()
1388
+ console.print("[dim]History cleared.[/dim]")
1389
+ continue
1390
+
1391
+ if lower_input == "status":
1392
+ if session.has_pending_changes:
1393
+ console.print("[yellow]You have pending changes awaiting confirmation.[/yellow]")
1394
+ else:
1395
+ console.print("[green]No pending changes.[/green]")
1396
+ continue
1397
+
1398
+ # Process through agent
1399
+ with console.status("[bold green]Thinking..."):
1400
+ response = session.send_message(user_input)
1401
+
1402
+ # Display response
1403
+ console.print(f"\n[bold green]Agent[/bold green]")
1404
+
1405
+ # Use markdown rendering for better formatting
1406
+ try:
1407
+ console.print(Markdown(response.content))
1408
+ except Exception:
1409
+ console.print(response.content)
1410
+
1411
+ # Show confirmation prompt if needed
1412
+ if response.pending_action:
1413
+ console.print("\n[yellow]Apply these changes? (yes/no)[/yellow]")
1414
+
1415
+ except KeyboardInterrupt:
1416
+ console.print("\n[dim]Use 'quit' to exit[/dim]")
1417
+ except Exception as e:
1418
+ console.print(f"[red]Error:[/red] {e}")
1419
+
1420
+
1421
+ def _show_agent_help():
1422
+ """Show agent help message."""
1423
+ help_text = """
1424
+ [bold]Code Generation[/bold]
1425
+ • "create a function to parse JSON files"
1426
+ • "write a class for managing database connections"
1427
+ • "generate a REST API endpoint for users"
1428
+
1429
+ [bold]Code Review[/bold]
1430
+ • "review src/main.py"
1431
+ • "check utils.py for security issues"
1432
+ • "analyze the config module"
1433
+
1434
+ [bold]Code Editing[/bold]
1435
+ • "edit src/api.py to add error handling"
1436
+ • "fix the bug in utils.py line 42"
1437
+ • "add logging to the main function"
1438
+
1439
+ [bold]Code Explanation[/bold]
1440
+ • "explain src/config.py"
1441
+ • "how does the authentication work?"
1442
+ • "what does this function do?"
1443
+
1444
+ [bold]Refactoring[/bold]
1445
+ • "refactor utils.py to use async/await"
1446
+ • "improve the code quality of main.py"
1447
+ • "simplify the database module"
1448
+
1449
+ [bold]Test Generation[/bold]
1450
+ • "generate tests for src/api.py"
1451
+ • "write unit tests for the User class"
1452
+ • "create pytest tests for utils.py"
1453
+
1454
+ [bold]Project Info[/bold]
1455
+ • "show project structure"
1456
+ • "what languages are used?"
1457
+ • "list all Python files"
1458
+
1459
+ [bold]Special Commands[/bold]
1460
+ • help - Show this help
1461
+ • status - Check for pending changes
1462
+ • clear - Clear conversation history
1463
+ • quit - Exit agent mode
1464
+ """
1465
+ console.print(Panel(help_text, title="Agent Help", border_style="cyan"))
1466
+
1467
+
1468
+ @main.command("agent-review")
1469
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
1470
+ @click.option("--path", "-p", type=click.Path(exists=True, path_type=Path),
1471
+ default=".", help="Project root path")
1472
+ @click.pass_context
1473
+ def agent_review(ctx, file: Path, path: Path):
1474
+ """Quick code review using the agent.
1475
+
1476
+ Examples:
1477
+ ai-assist agent-review src/main.py
1478
+ ai-assist agent-review utils.py --path ./my-project
1479
+ """
1480
+ from ai_code_assistant.agent import CodeAgent
1481
+
1482
+ config, llm = get_components(ctx.obj.get("config_path"))
1483
+ agent = CodeAgent(llm, path.resolve())
1484
+
1485
+ console.print(f"\n[bold]Reviewing {file}...[/bold]\n")
1486
+
1487
+ with console.status("[bold green]Analyzing..."):
1488
+ response = agent.process(f"review {file}")
1489
+
1490
+ console.print(response.message)
1491
+
1492
+
1493
+ @main.command("agent-generate")
1494
+ @click.argument("description")
1495
+ @click.option("--file", "-f", type=click.Path(path_type=Path), help="Output file path")
1496
+ @click.option("--language", "-l", help="Programming language")
1497
+ @click.option("--path", "-p", type=click.Path(exists=True, path_type=Path),
1498
+ default=".", help="Project root path")
1499
+ @click.option("--apply", "-a", is_flag=True, help="Apply changes without confirmation")
1500
+ @click.pass_context
1501
+ def agent_generate(ctx, description: str, file: Optional[Path], language: Optional[str],
1502
+ path: Path, apply: bool):
1503
+ """Generate code using the agent.
1504
+
1505
+ Examples:
1506
+ ai-assist agent-generate "a function to validate email"
1507
+ ai-assist agent-generate "REST API for users" -f src/api.py
1508
+ ai-assist agent-generate "sorting algorithm" -l python
1509
+ """
1510
+ from ai_code_assistant.agent import CodeAgent
1511
+
1512
+ config, llm = get_components(ctx.obj.get("config_path"))
1513
+ agent = CodeAgent(llm, path.resolve())
1514
+
1515
+ # Build the request
1516
+ request = description
1517
+ if file:
1518
+ request = f"create {file}: {description}"
1519
+ if language:
1520
+ request = f"{request} in {language}"
1521
+
1522
+ console.print(f"\n[bold]Generating code...[/bold]\n")
1523
+
1524
+ with console.status("[bold green]Generating..."):
1525
+ response = agent.process(request)
1526
+
1527
+ console.print(response.message)
1528
+
1529
+ if response.requires_confirmation:
1530
+ if apply:
1531
+ success, msg = agent.confirm_changes()
1532
+ console.print(f"\n{msg}")
1533
+ else:
1534
+ if click.confirm("\nApply these changes?"):
1535
+ success, msg = agent.confirm_changes()
1536
+ console.print(f"\n{msg}")
1537
+ else:
1538
+ console.print("[dim]Changes discarded.[/dim]")
1539
+
1540
+
1541
+ @main.command("agent-explain")
1542
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
1543
+ @click.option("--path", "-p", type=click.Path(exists=True, path_type=Path),
1544
+ default=".", help="Project root path")
1545
+ @click.pass_context
1546
+ def agent_explain(ctx, file: Path, path: Path):
1547
+ """Explain code using the agent.
1548
+
1549
+ Examples:
1550
+ ai-assist agent-explain src/main.py
1551
+ ai-assist agent-explain config.py --path ./my-project
1552
+ """
1553
+ from ai_code_assistant.agent import CodeAgent
1554
+ from rich.markdown import Markdown
1555
+
1556
+ config, llm = get_components(ctx.obj.get("config_path"))
1557
+ agent = CodeAgent(llm, path.resolve())
1558
+
1559
+ console.print(f"\n[bold]Explaining {file}...[/bold]\n")
1560
+
1561
+ with console.status("[bold green]Analyzing..."):
1562
+ response = agent.process(f"explain {file}")
1563
+
1564
+ try:
1565
+ console.print(Markdown(response.message))
1566
+ except Exception:
1567
+ console.print(response.message)
1568
+
1569
+
1570
+ if __name__ == "__main__":
1571
+ main()