codemate-cli 1.0.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.
@@ -0,0 +1,436 @@
1
+ import asyncio
2
+ from typing import AsyncGenerator, Optional, Dict, Tuple, List
3
+ from rich.console import Console, Group
4
+ from rich.markdown import Markdown
5
+ from rich.live import Live
6
+ from rich.panel import Panel
7
+ from rich.syntax import Syntax
8
+ from rich.text import Text
9
+ import re
10
+ from codemate.utils.error_handler import ErrorHandler
11
+
12
+ class StreamingHandler:
13
+ """Handler for streaming responses with rich formatting"""
14
+
15
+ def __init__(self, console: Console):
16
+ self.console = console
17
+ self.error_handler = ErrorHandler(console)
18
+ # Standard markdown code blocks
19
+ self.code_block_pattern = re.compile(r'```(\w+)?\n(.*?)```', re.DOTALL)
20
+
21
+ def _parse_code_blocks(self, text: str) -> list:
22
+ """Parse code blocks and identify their type"""
23
+ blocks = []
24
+
25
+ for match in self.code_block_pattern.finditer(text):
26
+ language = match.group(1) or 'text'
27
+ content = match.group(2).strip()
28
+
29
+ # Check for special markers
30
+ is_file = content.startswith('# FILE:')
31
+ is_command = content.startswith('# COMMAND')
32
+
33
+ if is_file:
34
+ # Extract filename from first line
35
+ first_line = content.split('\n')[0]
36
+ filename = first_line.replace('# FILE:', '').strip()
37
+ # Remove the FILE marker line from content
38
+ code_content = '\n'.join(content.split('\n')[1:]).strip()
39
+
40
+ blocks.append({
41
+ 'type': 'file_code',
42
+ 'start': match.start(),
43
+ 'end': match.end(),
44
+ 'language': language,
45
+ 'filename': filename,
46
+ 'content': code_content
47
+ })
48
+ elif is_command:
49
+ # Remove the COMMAND marker line from content
50
+ command_content = '\n'.join(content.split('\n')[1:]).strip()
51
+
52
+ blocks.append({
53
+ 'type': 'command',
54
+ 'start': match.start(),
55
+ 'end': match.end(),
56
+ 'content': command_content
57
+ })
58
+ else:
59
+ # Regular code block
60
+ blocks.append({
61
+ 'type': 'regular_code',
62
+ 'start': match.start(),
63
+ 'end': match.end(),
64
+ 'language': language,
65
+ 'content': content
66
+ })
67
+
68
+ blocks.sort(key=lambda x: x['start'])
69
+ return blocks
70
+
71
+ def _render_block(self, block: dict) -> Panel:
72
+ """Render a single code block with appropriate styling"""
73
+
74
+ if block['type'] == 'file_code':
75
+ syntax = Syntax(
76
+ block['content'],
77
+ block['language'],
78
+ theme="monokai",
79
+ line_numbers=False,
80
+ word_wrap=False
81
+ )
82
+ return Panel(
83
+ syntax,
84
+ border_style="#48AEF3",
85
+ padding=(1, 2),
86
+ title=f"[bold color(#48AEF3)]📄 {block['filename']}[/bold color(#48AEF3)]",
87
+ title_align="left"
88
+ )
89
+
90
+ elif block['type'] == 'command':
91
+ syntax = Syntax(
92
+ block['content'],
93
+ "bash",
94
+ theme="monokai",
95
+ line_numbers=False,
96
+ word_wrap=True
97
+ )
98
+ return Panel(
99
+ syntax,
100
+ border_style="yellow",
101
+ padding=(1, 2),
102
+ title="[bold yellow]⚡ COMMAND[/bold yellow]",
103
+ title_align="left"
104
+ )
105
+
106
+ elif block['type'] == 'regular_code':
107
+ syntax = Syntax(
108
+ block['content'],
109
+ block['language'],
110
+ theme="monokai",
111
+ line_numbers=False,
112
+ word_wrap=False
113
+ )
114
+ return Panel(
115
+ syntax,
116
+ border_style="#2FCACE",
117
+ padding=(1, 2),
118
+ title=f"[bold color(#2FCACE)]{block['language'].upper()}[/bold color(#2FCACE)]",
119
+ title_align="left"
120
+ )
121
+
122
+
123
+ async def handle_streaming_response_async(
124
+ self,
125
+ generator: AsyncGenerator[Dict[str, str], None]
126
+ ) -> Tuple[str, Optional[Dict], List[Dict]]:
127
+ """
128
+ Handle streaming response - accumulate all content and render once at the end
129
+
130
+ Flow:
131
+ 1. Thinking... (status only, not printed)
132
+ 2. 🔧 Searching knowledge base... (PRINTED to UI)
133
+ 3. ✓ Completed knowledge base search (PRINTED to UI)
134
+ 4. Thinking... (status only, not printed)
135
+ 5. Final formatted message (PRINTED)
136
+
137
+ Returns:
138
+ Tuple of (message_content, assistant_entry, tool_results)
139
+ """
140
+ accumulated_text = ""
141
+ reasoning_text = ""
142
+ tool_calls = []
143
+ tool_results = []
144
+ active_tools = {}
145
+ should_stop = False
146
+ tool_search_shown = False
147
+ status_context = None
148
+
149
+ try:
150
+ # Show initial "Thinking..." as status only (not printed to UI)
151
+ status_context = self.console.status("[dim]Thinking...[/dim]", spinner="dots")
152
+ status_context.start()
153
+
154
+ async for chunk in generator:
155
+ if should_stop:
156
+ break
157
+
158
+ chunk_type = chunk.get("type")
159
+
160
+ # Handle error chunks from API
161
+ if chunk_type == "error":
162
+ if status_context:
163
+ status_context.stop()
164
+ self.error_handler.handle_streaming_error(chunk)
165
+ should_stop = True
166
+ break
167
+
168
+ elif chunk_type == "reasoning":
169
+ content = chunk.get("content", "")
170
+ reasoning_text += content
171
+
172
+ elif chunk_type == "message":
173
+ content = chunk.get("content", "")
174
+ accumulated_text += content
175
+
176
+ elif chunk_type == "tool_calls":
177
+ # Store tool calls for history
178
+ tool_calls = chunk.get("tool_calls", [])
179
+
180
+ # PRINT to UI: Start of knowledge base search
181
+ if tool_calls and not tool_search_shown:
182
+ if status_context:
183
+ status_context.stop()
184
+ self.console.print()
185
+ self.console.print("[color(#2FCACE)]🔧 Searching knowledge base...[/color(#2FCACE)]")
186
+ tool_search_shown = True
187
+
188
+ elif chunk_type == "tool_call_start":
189
+ tool_call = chunk.get("tool_call", {})
190
+ tool_id = tool_call.get("id")
191
+ tool_name = tool_call.get("name", "unknown")
192
+
193
+ if tool_id:
194
+ active_tools[tool_id] = tool_name
195
+
196
+ elif chunk_type == "tool_call_complete":
197
+ tool_call = chunk.get("tool_call", {})
198
+ tool_id = tool_call.get("id")
199
+ tool_name = tool_call.get("name")
200
+ tool_result = tool_call.get("result")
201
+
202
+ # Remove from active tools
203
+ if tool_id in active_tools:
204
+ del active_tools[tool_id]
205
+
206
+ # Store tool result for history
207
+ if tool_id and tool_result:
208
+ if isinstance(tool_result, dict):
209
+ import json
210
+ result_content = json.dumps(tool_result)
211
+ else:
212
+ result_content = str(tool_result)
213
+
214
+ tool_results.append({
215
+ "role": "tool",
216
+ "tool_call_id": tool_id,
217
+ "content": result_content,
218
+ "name": tool_name
219
+ })
220
+
221
+ # PRINT to UI: When all tools are complete
222
+ if not active_tools and tool_search_shown:
223
+ self.console.print("[green]✓ Completed knowledge base search[/green]")
224
+ self.console.print()
225
+ # Restart "Thinking..." status (not printed, just spinner)
226
+ status_context = self.console.status("[dim]Thinking...[/dim]", spinner="dots")
227
+ status_context.start()
228
+ tool_search_shown = False
229
+
230
+ elif chunk_type == "usage":
231
+ # Optionally capture usage stats
232
+ pass
233
+
234
+ # Stop any active status before rendering
235
+ if status_context:
236
+ status_context.stop()
237
+
238
+ # After streaming completes (only if no error occurred)
239
+ if not should_stop:
240
+ # Now PRINT the complete formatted response to UI
241
+ if accumulated_text.strip() or reasoning_text.strip():
242
+ self._render_final_content(accumulated_text, reasoning_text)
243
+ self.console.print() # Add final newline
244
+
245
+ # Build assistant history entry
246
+ assistant_entry = {
247
+ "role": "assistant",
248
+ "content": accumulated_text.strip()
249
+ }
250
+
251
+ # Add tool calls if present
252
+ if tool_calls:
253
+ assistant_entry["tool_calls"] = tool_calls
254
+
255
+ # Add reasoning content if present
256
+ if reasoning_text.strip():
257
+ assistant_entry["reasoning_content"] = reasoning_text.strip()
258
+
259
+ return accumulated_text.strip(), assistant_entry, tool_results
260
+ else:
261
+ # Error occurred, return empty
262
+ return "", None, []
263
+
264
+ except Exception as e:
265
+ # Stop the thinking indicator if it's still active
266
+ if status_context:
267
+ status_context.stop()
268
+ self.error_handler.handle_error(e)
269
+ return "", None, []
270
+
271
+ finally:
272
+ # Always ensure the generator is properly closed
273
+ try:
274
+ await generator.aclose()
275
+ except Exception:
276
+ pass
277
+
278
+
279
+ def handle_streaming_response(
280
+ self,
281
+ generator: AsyncGenerator[Dict[str, str], None]
282
+ ) -> Tuple[str, Optional[Dict], List[Dict]]:
283
+ """Handle streaming response (SYNC wrapper for CLI usage)"""
284
+ try:
285
+ # Check if there's already a running event loop
286
+ try:
287
+ loop = asyncio.get_running_loop()
288
+ # If we're in an async context, we need to create a new task and wait for it
289
+ import concurrent.futures
290
+ import threading
291
+
292
+ def run_in_thread():
293
+ """Run the async method in a separate thread with its own event loop"""
294
+ new_loop = asyncio.new_event_loop()
295
+ asyncio.set_event_loop(new_loop)
296
+ try:
297
+ return new_loop.run_until_complete(self.handle_streaming_response_async(generator))
298
+ finally:
299
+ new_loop.close()
300
+
301
+ with concurrent.futures.ThreadPoolExecutor() as executor:
302
+ future = executor.submit(run_in_thread)
303
+ return future.result()
304
+
305
+ except RuntimeError:
306
+ # No running event loop, safe to use asyncio.run()
307
+ return asyncio.run(self.handle_streaming_response_async(generator))
308
+
309
+ except Exception as e:
310
+ # Log for debugging
311
+ import logging
312
+ logging.debug(f"Exception in sync streaming wrapper: {e}")
313
+ self.console.print(f"[red]Error during streaming: {str(e)}[/red]")
314
+ return "", None, []
315
+
316
+ def _render_final_content(self, text: str, reasoning_text: str = ""):
317
+ """
318
+ Render final formatted content after streaming completes
319
+ """
320
+ # Render reasoning if present
321
+ if reasoning_text.strip():
322
+ reasoning_panel = Panel(
323
+ Text(reasoning_text, style="dim italic"),
324
+ border_style="dim",
325
+ padding=(0, 1),
326
+ title="[dim]Reasoning[/dim]",
327
+ title_align="left"
328
+ )
329
+ self.console.print(reasoning_panel)
330
+ self.console.print()
331
+
332
+ # Parse all code blocks
333
+ blocks = self._parse_code_blocks(text)
334
+
335
+ if blocks:
336
+ # Render with code blocks
337
+ last_pos = 0
338
+
339
+ for block in blocks:
340
+ # Add text before this block
341
+ if block['start'] > last_pos:
342
+ pre_text = text[last_pos:block['start']].strip()
343
+ if pre_text:
344
+ # Render as markdown for better formatting
345
+ self.console.print(Markdown(pre_text, code_theme="monokai"))
346
+ self.console.print()
347
+
348
+ # Add the rendered block (with panel)
349
+ self.console.print(self._render_block(block))
350
+ self.console.print()
351
+
352
+ last_pos = block['end']
353
+
354
+ # Add any remaining text
355
+ if last_pos < len(text):
356
+ post_text = text[last_pos:].strip()
357
+ if post_text:
358
+ self.console.print(Markdown(post_text, code_theme="monokai"))
359
+ else:
360
+ # No code blocks, render as markdown
361
+ if text.strip():
362
+ self.console.print(Markdown(text, code_theme="monokai"))
363
+
364
+ def display_response(self, response_data: dict):
365
+ """Display a non-streaming response"""
366
+ try:
367
+ if "choices" in response_data and len(response_data["choices"]) > 0:
368
+ message = response_data["choices"][0].get("message", {})
369
+ content = message.get("content", "")
370
+
371
+ if content:
372
+ self._render_final_content(content)
373
+ else:
374
+ self.console.print("[yellow]No response content received.[/yellow]")
375
+ else:
376
+ self.console.print("[yellow]Invalid response format.[/yellow]")
377
+
378
+ except Exception as e:
379
+ self.console.print(f"[red]Error displaying response: {str(e)}[/red]")
380
+
381
+ def display_error(self, error_message: str):
382
+ """Display an error message"""
383
+ self.console.print(Panel(
384
+ f"[red]{error_message}[/red]",
385
+ border_style="red",
386
+ title="[bold red]❌ Error[/bold red]",
387
+ padding=(1, 2)
388
+ ))
389
+
390
+ def display_code_block(self, code: str, language: str = "python"):
391
+ """Display a formatted code block"""
392
+ syntax = Syntax(
393
+ code,
394
+ language,
395
+ theme="monokai",
396
+ line_numbers=False,
397
+ word_wrap=False
398
+ )
399
+ self.console.print(Panel(
400
+ syntax,
401
+ border_style="#48AEF3",
402
+ padding=(1, 2),
403
+ title=f"[bold color(#48AEF3)]{language.upper()}[/bold color(#48AEF3)]"
404
+ ))
405
+
406
+ def display_thinking(self, message: str = "Thinking..."):
407
+ """Display a thinking/processing message"""
408
+ return self.console.status(
409
+ f"[bold color(#2FCACE)]{message}",
410
+ spinner="dots"
411
+ )
412
+
413
+
414
+ class MarkdownRenderer:
415
+ """Enhanced markdown renderer with custom styling"""
416
+
417
+ def __init__(self, console: Console):
418
+ self.console = console
419
+
420
+ def render(self, markdown_text: str, title: Optional[str] = None):
421
+ """Render markdown with enhanced styling"""
422
+ markdown = Markdown(
423
+ markdown_text,
424
+ code_theme="monokai",
425
+ inline_code_theme="monokai"
426
+ )
427
+
428
+ if title:
429
+ self.console.print(Panel(
430
+ markdown,
431
+ title=f"[bold color(#2FCACE)]{title}[/bold color(#2FCACE)]",
432
+ border_style="#2FCACE",
433
+ padding=(1, 2)
434
+ ))
435
+ else:
436
+ self.console.print(markdown)
@@ -0,0 +1,21 @@
1
+ """
2
+ Utility functions for CodeMate CLI
3
+ """
4
+
5
+ from codemate.utils.errors import (
6
+ CodeMateError,
7
+ APIError,
8
+ ConfigError,
9
+ AuthenticationError,
10
+ NetworkError,
11
+ handle_errors,
12
+ )
13
+
14
+ __all__ = [
15
+ "CodeMateError",
16
+ "APIError",
17
+ "ConfigError",
18
+ "AuthenticationError",
19
+ "NetworkError",
20
+ "handle_errors",
21
+ ]
codemate/utils/auth.py ADDED
@@ -0,0 +1,164 @@
1
+ """
2
+ Authentication utilities
3
+ """
4
+
5
+ import os
6
+ import hashlib
7
+ from typing import Optional
8
+ from pathlib import Path
9
+
10
+
11
+ class APIKeyManager:
12
+ """Manage API key storage and validation"""
13
+
14
+ @staticmethod
15
+ def validate_key_format(api_key: str) -> bool:
16
+ """
17
+ Validate API key format
18
+
19
+ Args:
20
+ api_key: API key to validate
21
+
22
+ Returns:
23
+ True if format is valid
24
+ """
25
+ if not api_key or not isinstance(api_key, str):
26
+ return False
27
+
28
+ # Basic validation - adjust based on your API key format
29
+ if len(api_key) < 10:
30
+ return False
31
+
32
+ # Check for common prefixes
33
+ valid_prefixes = ['sk-', 'key-', 'api-']
34
+ has_valid_prefix = any(api_key.startswith(prefix) for prefix in valid_prefixes)
35
+
36
+ return has_valid_prefix or len(api_key) >= 20
37
+
38
+ @staticmethod
39
+ def mask_key(api_key: str, show_first: int = 8, show_last: int = 4) -> str:
40
+ """
41
+ Mask API key for display
42
+
43
+ Args:
44
+ api_key: API key to mask
45
+ show_first: Number of characters to show at start
46
+ show_last: Number of characters to show at end
47
+
48
+ Returns:
49
+ Masked key string
50
+ """
51
+ if not api_key:
52
+ return "Not set"
53
+
54
+ if len(api_key) <= show_first + show_last:
55
+ return "*" * len(api_key)
56
+
57
+ return f"{api_key[:show_first]}...{api_key[-show_last:]}"
58
+
59
+ @staticmethod
60
+ def hash_key(api_key: str) -> str:
61
+ """
62
+ Create a hash of the API key for logging/tracking
63
+
64
+ Args:
65
+ api_key: API key to hash
66
+
67
+ Returns:
68
+ SHA256 hash of the key
69
+ """
70
+ return hashlib.sha256(api_key.encode()).hexdigest()[:16]
71
+
72
+
73
+ class EnvironmentAuth:
74
+ """Handle authentication from environment variables"""
75
+
76
+ @staticmethod
77
+ def get_api_key() -> Optional[str]:
78
+ """Get API key from environment"""
79
+ return os.environ.get('CODEMATE_API_KEY')
80
+
81
+ @staticmethod
82
+ def get_endpoint() -> Optional[str]:
83
+ """Get endpoint from environment"""
84
+ return os.environ.get('CODEMATE_ENDPOINT')
85
+
86
+ @staticmethod
87
+ def set_env_var(key: str, value: str):
88
+ """Set environment variable"""
89
+ os.environ[key] = value
90
+
91
+ @staticmethod
92
+ def has_env_auth() -> bool:
93
+ """Check if environment authentication is configured"""
94
+ return bool(os.environ.get('CODEMATE_API_KEY'))
95
+
96
+
97
+ class SecureStorage:
98
+ """Secure storage for sensitive data"""
99
+
100
+ def __init__(self, storage_dir: Path):
101
+ self.storage_dir = storage_dir
102
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ def store_secure(self, key: str, value: str):
105
+ """
106
+ Store data securely (basic implementation)
107
+
108
+ For production, consider using system keyring:
109
+ - macOS: Keychain
110
+ - Windows: Credential Manager
111
+ - Linux: Secret Service API
112
+ """
113
+ file_path = self.storage_dir / f".{key}"
114
+
115
+ # Set restrictive permissions (Unix-like systems)
116
+ try:
117
+ file_path.touch(mode=0o600, exist_ok=True)
118
+ except Exception:
119
+ pass # Windows doesn't support mode parameter
120
+
121
+ with open(file_path, 'w') as f:
122
+ f.write(value)
123
+
124
+ def retrieve_secure(self, key: str) -> Optional[str]:
125
+ """Retrieve securely stored data"""
126
+ file_path = self.storage_dir / f".{key}"
127
+
128
+ if not file_path.exists():
129
+ return None
130
+
131
+ try:
132
+ with open(file_path, 'r') as f:
133
+ return f.read().strip()
134
+ except Exception:
135
+ return None
136
+
137
+ def delete_secure(self, key: str):
138
+ """Delete securely stored data"""
139
+ file_path = self.storage_dir / f".{key}"
140
+ file_path.unlink(missing_ok=True)
141
+
142
+
143
+ def verify_api_connection(api_key: str, endpoint: str) -> bool:
144
+ """
145
+ Verify API connection with given credentials
146
+
147
+ Args:
148
+ api_key: API key to test
149
+ endpoint: API endpoint to test
150
+
151
+ Returns:
152
+ True if connection successful
153
+ """
154
+ import httpx
155
+
156
+ try:
157
+ with httpx.Client(timeout=5.0) as client:
158
+ response = client.get(
159
+ f"{endpoint}/health",
160
+ headers={"Authorization": f"Bearer {api_key}"}
161
+ )
162
+ return response.status_code == 200
163
+ except Exception:
164
+ return False