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.
- codemate/__init__.py +17 -0
- codemate/__main__.py +10 -0
- codemate/cli.py +815 -0
- codemate/client.py +311 -0
- codemate/commands/__init__.py +6 -0
- codemate/commands/chat.py +0 -0
- codemate/commands/config.py +103 -0
- codemate/commands/help.py +298 -0
- codemate/commands/kb_commands.py +749 -0
- codemate/config.py +233 -0
- codemate/ui/__init__.py +10 -0
- codemate/ui/markdown.py +212 -0
- codemate/ui/renderer.py +159 -0
- codemate/ui/streaming.py +436 -0
- codemate/utils/__init__.py +21 -0
- codemate/utils/auth.py +164 -0
- codemate/utils/error_handler.py +277 -0
- codemate/utils/errors.py +156 -0
- codemate/utils/kb_parser.py +111 -0
- codemate_cli-1.0.0.dist-info/METADATA +452 -0
- codemate_cli-1.0.0.dist-info/RECORD +25 -0
- codemate_cli-1.0.0.dist-info/WHEEL +5 -0
- codemate_cli-1.0.0.dist-info/entry_points.txt +3 -0
- codemate_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- codemate_cli-1.0.0.dist-info/top_level.txt +1 -0
codemate/ui/streaming.py
ADDED
|
@@ -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
|