zyora-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zyora/__init__.py +4 -0
- zyora/__main__.py +6 -0
- zyora/client.py +194 -0
- zyora/commands/__init__.py +1 -0
- zyora/commands/config_cmd.py +106 -0
- zyora/commands/scan.py +188 -0
- zyora/config.py +97 -0
- zyora/main.py +219 -0
- zyora/repl.py +328 -0
- zyora/utils/__init__.py +15 -0
- zyora/utils/context.py +152 -0
- zyora/utils/display.py +100 -0
- zyora/utils/markdown.py +53 -0
- zyora_cli-0.1.0.dist-info/METADATA +269 -0
- zyora_cli-0.1.0.dist-info/RECORD +17 -0
- zyora_cli-0.1.0.dist-info/WHEEL +4 -0
- zyora_cli-0.1.0.dist-info/entry_points.txt +2 -0
zyora/__init__.py
ADDED
zyora/__main__.py
ADDED
zyora/client.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Zyora API client with streaming support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import AsyncIterator, Iterator, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .config import load_config, get_api_key
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Message(BaseModel):
|
|
12
|
+
"""Chat message."""
|
|
13
|
+
|
|
14
|
+
role: str
|
|
15
|
+
content: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChatResponse(BaseModel):
|
|
19
|
+
"""Chat completion response."""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
model: str
|
|
23
|
+
content: str
|
|
24
|
+
finish_reason: Optional[str] = None
|
|
25
|
+
usage: Optional[dict] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ZyoraClient:
|
|
29
|
+
"""Zyora API client."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_key: Optional[str] = None,
|
|
34
|
+
api_base: Optional[str] = None,
|
|
35
|
+
model: Optional[str] = None,
|
|
36
|
+
timeout: float = 120.0,
|
|
37
|
+
):
|
|
38
|
+
self.config = load_config()
|
|
39
|
+
self.api_key = api_key or get_api_key()
|
|
40
|
+
self.api_base = api_base or self.config.api_base
|
|
41
|
+
self.model = model or self.config.model
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
|
|
44
|
+
if not self.api_key:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"API key not found. Set ZYORA_API_KEY or run 'zyora config set api_key <key>'"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _get_headers(self) -> dict:
|
|
50
|
+
"""Get request headers."""
|
|
51
|
+
return {
|
|
52
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def chat(
|
|
57
|
+
self,
|
|
58
|
+
messages: list[Message],
|
|
59
|
+
max_tokens: Optional[int] = None,
|
|
60
|
+
temperature: Optional[float] = None,
|
|
61
|
+
stream: bool = False,
|
|
62
|
+
) -> ChatResponse | Iterator[str]:
|
|
63
|
+
"""Send a chat completion request."""
|
|
64
|
+
url = f"{self.api_base}/v1/chat/completions"
|
|
65
|
+
|
|
66
|
+
payload = {
|
|
67
|
+
"model": self.model,
|
|
68
|
+
"messages": [m.model_dump() for m in messages],
|
|
69
|
+
"max_tokens": max_tokens or self.config.max_tokens,
|
|
70
|
+
"temperature": temperature or self.config.temperature,
|
|
71
|
+
"stream": stream,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if stream:
|
|
75
|
+
return self._stream_chat(url, payload)
|
|
76
|
+
else:
|
|
77
|
+
return self._sync_chat(url, payload)
|
|
78
|
+
|
|
79
|
+
def _sync_chat(self, url: str, payload: dict) -> ChatResponse:
|
|
80
|
+
"""Synchronous chat completion."""
|
|
81
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
82
|
+
response = client.post(url, headers=self._get_headers(), json=payload)
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
data = response.json()
|
|
85
|
+
|
|
86
|
+
# Handle potential response format variations
|
|
87
|
+
if "choices" not in data or not data["choices"]:
|
|
88
|
+
raise ValueError(f"Invalid response format: {data}")
|
|
89
|
+
|
|
90
|
+
choice = data["choices"][0]
|
|
91
|
+
content = ""
|
|
92
|
+
|
|
93
|
+
if "message" in choice:
|
|
94
|
+
content = choice["message"].get("content", "")
|
|
95
|
+
elif "text" in choice:
|
|
96
|
+
content = choice["text"]
|
|
97
|
+
|
|
98
|
+
return ChatResponse(
|
|
99
|
+
id=data.get("id", ""),
|
|
100
|
+
model=data.get("model", self.model),
|
|
101
|
+
content=content,
|
|
102
|
+
finish_reason=choice.get("finish_reason"),
|
|
103
|
+
usage=data.get("usage"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _stream_chat(self, url: str, payload: dict) -> Iterator[str]:
|
|
107
|
+
"""Streaming chat completion."""
|
|
108
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
109
|
+
with client.stream(
|
|
110
|
+
"POST", url, headers=self._get_headers(), json=payload
|
|
111
|
+
) as response:
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
for line in response.iter_lines():
|
|
114
|
+
if line.startswith("data: "):
|
|
115
|
+
data_str = line[6:]
|
|
116
|
+
if data_str.strip() == "[DONE]":
|
|
117
|
+
break
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(data_str)
|
|
120
|
+
if "choices" in data and data["choices"]:
|
|
121
|
+
delta = data["choices"][0].get("delta", {})
|
|
122
|
+
if "content" in delta:
|
|
123
|
+
yield delta["content"]
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
async def achat(
|
|
128
|
+
self,
|
|
129
|
+
messages: list[Message],
|
|
130
|
+
max_tokens: Optional[int] = None,
|
|
131
|
+
temperature: Optional[float] = None,
|
|
132
|
+
stream: bool = False,
|
|
133
|
+
) -> ChatResponse | AsyncIterator[str]:
|
|
134
|
+
"""Async chat completion request."""
|
|
135
|
+
url = f"{self.api_base}/v1/chat/completions"
|
|
136
|
+
|
|
137
|
+
payload = {
|
|
138
|
+
"model": self.model,
|
|
139
|
+
"messages": [m.model_dump() for m in messages],
|
|
140
|
+
"max_tokens": max_tokens or self.config.max_tokens,
|
|
141
|
+
"temperature": temperature or self.config.temperature,
|
|
142
|
+
"stream": stream,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if stream:
|
|
146
|
+
return self._async_stream_chat(url, payload)
|
|
147
|
+
else:
|
|
148
|
+
return await self._async_chat(url, payload)
|
|
149
|
+
|
|
150
|
+
async def _async_chat(self, url: str, payload: dict) -> ChatResponse:
|
|
151
|
+
"""Async synchronous chat completion."""
|
|
152
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
153
|
+
response = await client.post(url, headers=self._get_headers(), json=payload)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
data = response.json()
|
|
156
|
+
|
|
157
|
+
return ChatResponse(
|
|
158
|
+
id=data.get("id", ""),
|
|
159
|
+
model=data.get("model", self.model),
|
|
160
|
+
content=data["choices"][0]["message"]["content"],
|
|
161
|
+
finish_reason=data["choices"][0].get("finish_reason"),
|
|
162
|
+
usage=data.get("usage"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def _async_stream_chat(self, url: str, payload: dict) -> AsyncIterator[str]:
|
|
166
|
+
"""Async streaming chat completion."""
|
|
167
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
168
|
+
async with client.stream(
|
|
169
|
+
"POST", url, headers=self._get_headers(), json=payload
|
|
170
|
+
) as response:
|
|
171
|
+
response.raise_for_status()
|
|
172
|
+
async for line in response.aiter_lines():
|
|
173
|
+
if line.startswith("data: "):
|
|
174
|
+
data_str = line[6:]
|
|
175
|
+
if data_str.strip() == "[DONE]":
|
|
176
|
+
break
|
|
177
|
+
try:
|
|
178
|
+
data = json.loads(data_str)
|
|
179
|
+
if "choices" in data and data["choices"]:
|
|
180
|
+
delta = data["choices"][0].get("delta", {})
|
|
181
|
+
if "content" in delta:
|
|
182
|
+
yield delta["content"]
|
|
183
|
+
except json.JSONDecodeError:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
def list_models(self) -> list[dict]:
|
|
187
|
+
"""List available models."""
|
|
188
|
+
url = f"{self.api_base}/v1/models"
|
|
189
|
+
|
|
190
|
+
with httpx.Client(timeout=30.0) as client:
|
|
191
|
+
response = client.get(url, headers=self._get_headers())
|
|
192
|
+
response.raise_for_status()
|
|
193
|
+
data = response.json()
|
|
194
|
+
return data.get("data", [])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Zyora CLI commands."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Configuration commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from ..config import load_config, save_config, get_config_path, ZyoraConfig
|
|
8
|
+
from ..utils.display import console, print_success, print_error
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Configuration management")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("show")
|
|
14
|
+
def show_config():
|
|
15
|
+
"""Show current configuration."""
|
|
16
|
+
config = load_config()
|
|
17
|
+
config_path = get_config_path()
|
|
18
|
+
|
|
19
|
+
console.print(f"\n[bold green]Configuration File:[/] {config_path}\n")
|
|
20
|
+
|
|
21
|
+
table = Table(title="Zyora Configuration", border_style="green")
|
|
22
|
+
table.add_column("Setting", style="cyan")
|
|
23
|
+
table.add_column("Value", style="white")
|
|
24
|
+
|
|
25
|
+
config_dict = config.model_dump()
|
|
26
|
+
for key, value in config_dict.items():
|
|
27
|
+
# Mask API key
|
|
28
|
+
if key == "api_key" and value:
|
|
29
|
+
display_value = f"{value[:8]}...{value[-4:]}" if len(value) > 12 else "***"
|
|
30
|
+
elif isinstance(value, list):
|
|
31
|
+
display_value = ", ".join(value[:5]) + ("..." if len(value) > 5 else "")
|
|
32
|
+
else:
|
|
33
|
+
display_value = str(value)
|
|
34
|
+
table.add_row(key, display_value)
|
|
35
|
+
|
|
36
|
+
console.print(table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("set")
|
|
40
|
+
def set_config(
|
|
41
|
+
key: str = typer.Argument(..., help="Configuration key"),
|
|
42
|
+
value: str = typer.Argument(..., help="Configuration value"),
|
|
43
|
+
):
|
|
44
|
+
"""Set a configuration value."""
|
|
45
|
+
config = load_config()
|
|
46
|
+
|
|
47
|
+
# Validate key
|
|
48
|
+
valid_keys = ["api_key", "api_base", "model", "max_tokens", "temperature", "stream", "theme"]
|
|
49
|
+
if key not in valid_keys:
|
|
50
|
+
print_error(f"Invalid key: {key}\nValid keys: {', '.join(valid_keys)}")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
# Convert value types
|
|
54
|
+
if key == "max_tokens":
|
|
55
|
+
value = int(value)
|
|
56
|
+
elif key == "temperature":
|
|
57
|
+
value = float(value)
|
|
58
|
+
elif key == "stream":
|
|
59
|
+
value = value.lower() in ("true", "1", "yes")
|
|
60
|
+
|
|
61
|
+
# Update config
|
|
62
|
+
setattr(config, key, value)
|
|
63
|
+
save_config(config)
|
|
64
|
+
|
|
65
|
+
print_success(f"Set {key} = {value if key != 'api_key' else '***'}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("get")
|
|
69
|
+
def get_config(key: str = typer.Argument(..., help="Configuration key")):
|
|
70
|
+
"""Get a configuration value."""
|
|
71
|
+
config = load_config()
|
|
72
|
+
|
|
73
|
+
if not hasattr(config, key):
|
|
74
|
+
print_error(f"Unknown configuration key: {key}")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
value = getattr(config, key)
|
|
78
|
+
|
|
79
|
+
# Mask API key
|
|
80
|
+
if key == "api_key" and value:
|
|
81
|
+
value = f"{value[:8]}...{value[-4:]}" if len(value) > 12 else "***"
|
|
82
|
+
|
|
83
|
+
console.print(f"[cyan]{key}[/]: {value}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("reset")
|
|
87
|
+
def reset_config(
|
|
88
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
89
|
+
):
|
|
90
|
+
"""Reset configuration to defaults."""
|
|
91
|
+
if not confirm:
|
|
92
|
+
confirm = typer.confirm("Reset all configuration to defaults?")
|
|
93
|
+
|
|
94
|
+
if confirm:
|
|
95
|
+
config = ZyoraConfig()
|
|
96
|
+
save_config(config)
|
|
97
|
+
print_success("Configuration reset to defaults")
|
|
98
|
+
else:
|
|
99
|
+
console.print("[dim]Cancelled[/]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command("path")
|
|
103
|
+
def show_path():
|
|
104
|
+
"""Show configuration file path."""
|
|
105
|
+
config_path = get_config_path()
|
|
106
|
+
console.print(f"[cyan]Config path:[/] {config_path}")
|
zyora/commands/scan.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Security scanning commands."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import typer
|
|
6
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from ..client import ZyoraClient, Message
|
|
11
|
+
from ..config import load_config
|
|
12
|
+
from ..utils.display import console, print_error, print_success, print_warning
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Security scanning commands")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SCAN_PROMPT = """You are a security expert. Analyze the following code for security vulnerabilities.
|
|
18
|
+
|
|
19
|
+
For each vulnerability found, provide:
|
|
20
|
+
1. **Vulnerability Type** (e.g., SQL Injection, XSS, Command Injection)
|
|
21
|
+
2. **CWE ID** (e.g., CWE-89)
|
|
22
|
+
3. **Severity** (Critical, High, Medium, Low)
|
|
23
|
+
4. **Location** (file and line number if possible)
|
|
24
|
+
5. **Description** of the vulnerability
|
|
25
|
+
6. **Remediation** - how to fix it
|
|
26
|
+
7. **Fixed Code** - provide the corrected code snippet
|
|
27
|
+
|
|
28
|
+
If no vulnerabilities are found, state that the code appears secure but recommend:
|
|
29
|
+
- Regular security audits
|
|
30
|
+
- Following security best practices
|
|
31
|
+
- Using security linters
|
|
32
|
+
|
|
33
|
+
Be thorough but avoid false positives. Only report actual security issues.
|
|
34
|
+
|
|
35
|
+
Code to analyze:
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scan_file(file_path: Path, client: ZyoraClient) -> Optional[str]:
|
|
40
|
+
"""Scan a single file for vulnerabilities."""
|
|
41
|
+
try:
|
|
42
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return f"Error reading file: {e}"
|
|
45
|
+
|
|
46
|
+
if not content.strip():
|
|
47
|
+
return "File is empty"
|
|
48
|
+
|
|
49
|
+
prompt = f"{SCAN_PROMPT}\n\n```{file_path.suffix[1:] if file_path.suffix else 'text'}\n# File: {file_path.name}\n{content}\n```"
|
|
50
|
+
|
|
51
|
+
messages = [Message(role="user", content=prompt)]
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
response = client.chat(messages, stream=False, temperature=0.1)
|
|
55
|
+
return response.content
|
|
56
|
+
except Exception as e:
|
|
57
|
+
return f"Error during scan: {e}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("file")
|
|
61
|
+
def scan_single_file(
|
|
62
|
+
file_path: Path = typer.Argument(..., help="Path to file to scan"),
|
|
63
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save results to file"),
|
|
64
|
+
):
|
|
65
|
+
"""Scan a single file for security vulnerabilities."""
|
|
66
|
+
if not file_path.exists():
|
|
67
|
+
print_error(f"File not found: {file_path}")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
config = load_config()
|
|
71
|
+
if not config.api_key:
|
|
72
|
+
print_error("API key not configured. Run 'zyora config set api_key <key>'")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
console.print(f"\n[bold green]Scanning:[/] {file_path}\n")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
client = ZyoraClient()
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
print_error(str(e))
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
with Progress(
|
|
84
|
+
SpinnerColumn(style="green"),
|
|
85
|
+
TextColumn("[progress.description]{task.description}"),
|
|
86
|
+
console=console,
|
|
87
|
+
) as progress:
|
|
88
|
+
progress.add_task("Analyzing code for vulnerabilities...", total=None)
|
|
89
|
+
result = scan_file(file_path, client)
|
|
90
|
+
|
|
91
|
+
if result:
|
|
92
|
+
console.print(Panel(result, title=f"[bold green]Scan Results: {file_path.name}[/]", border_style="green"))
|
|
93
|
+
|
|
94
|
+
if output:
|
|
95
|
+
output.write_text(result)
|
|
96
|
+
print_success(f"Results saved to {output}")
|
|
97
|
+
else:
|
|
98
|
+
print_warning("No results returned from scan")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command("dir")
|
|
102
|
+
def scan_directory(
|
|
103
|
+
directory: Path = typer.Argument(Path("."), help="Directory to scan"),
|
|
104
|
+
recursive: bool = typer.Option(True, "--recursive", "-r", help="Scan recursively"),
|
|
105
|
+
extensions: Optional[str] = typer.Option(None, "--ext", "-e", help="File extensions (comma-separated)"),
|
|
106
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save results to file"),
|
|
107
|
+
max_files: int = typer.Option(20, "--max-files", "-m", help="Maximum files to scan"),
|
|
108
|
+
):
|
|
109
|
+
"""Scan a directory for security vulnerabilities."""
|
|
110
|
+
if not directory.exists():
|
|
111
|
+
print_error(f"Directory not found: {directory}")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
config = load_config()
|
|
115
|
+
if not config.api_key:
|
|
116
|
+
print_error("API key not configured. Run 'zyora config set api_key <key>'")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
|
|
119
|
+
# Get file extensions to scan
|
|
120
|
+
if extensions:
|
|
121
|
+
exts = [f".{e.strip().lstrip('.')}" for e in extensions.split(",")]
|
|
122
|
+
else:
|
|
123
|
+
exts = config.scan_extensions
|
|
124
|
+
|
|
125
|
+
# Find files to scan
|
|
126
|
+
files_to_scan = []
|
|
127
|
+
pattern = "**/*" if recursive else "*"
|
|
128
|
+
|
|
129
|
+
for path in directory.glob(pattern):
|
|
130
|
+
if path.is_file() and path.suffix in exts:
|
|
131
|
+
# Skip common ignore patterns
|
|
132
|
+
path_str = str(path)
|
|
133
|
+
if any(ignore in path_str for ignore in ["node_modules", "__pycache__", ".git", "venv", ".venv"]):
|
|
134
|
+
continue
|
|
135
|
+
files_to_scan.append(path)
|
|
136
|
+
if len(files_to_scan) >= max_files:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if not files_to_scan:
|
|
140
|
+
print_warning(f"No files found with extensions: {', '.join(exts)}")
|
|
141
|
+
raise typer.Exit(0)
|
|
142
|
+
|
|
143
|
+
console.print(f"\n[bold green]Found {len(files_to_scan)} files to scan[/]\n")
|
|
144
|
+
|
|
145
|
+
# Show files table
|
|
146
|
+
table = Table(title="Files to Scan", border_style="green")
|
|
147
|
+
table.add_column("File", style="cyan")
|
|
148
|
+
table.add_column("Size", style="dim")
|
|
149
|
+
for f in files_to_scan:
|
|
150
|
+
table.add_row(str(f.relative_to(directory)), f"{f.stat().st_size:,} bytes")
|
|
151
|
+
console.print(table)
|
|
152
|
+
console.print()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
client = ZyoraClient()
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
print_error(str(e))
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
results = []
|
|
161
|
+
with Progress(
|
|
162
|
+
SpinnerColumn(style="green"),
|
|
163
|
+
TextColumn("[progress.description]{task.description}"),
|
|
164
|
+
console=console,
|
|
165
|
+
) as progress:
|
|
166
|
+
task = progress.add_task("Scanning files...", total=len(files_to_scan))
|
|
167
|
+
|
|
168
|
+
for file_path in files_to_scan:
|
|
169
|
+
progress.update(task, description=f"Scanning {file_path.name}...")
|
|
170
|
+
result = scan_file(file_path, client)
|
|
171
|
+
results.append((file_path, result))
|
|
172
|
+
progress.advance(task)
|
|
173
|
+
|
|
174
|
+
# Display results
|
|
175
|
+
all_results = []
|
|
176
|
+
for file_path, result in results:
|
|
177
|
+
console.print(Panel(
|
|
178
|
+
result or "No results",
|
|
179
|
+
title=f"[bold green]{file_path.relative_to(directory)}[/]",
|
|
180
|
+
border_style="green"
|
|
181
|
+
))
|
|
182
|
+
all_results.append(f"## {file_path}\n\n{result}\n\n---\n")
|
|
183
|
+
|
|
184
|
+
if output:
|
|
185
|
+
output.write_text("\n".join(all_results))
|
|
186
|
+
print_success(f"Results saved to {output}")
|
|
187
|
+
|
|
188
|
+
print_success(f"Scanned {len(files_to_scan)} files")
|
zyora/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Configuration management for Zyora CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import toml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ZyoraConfig(BaseModel):
|
|
11
|
+
"""Zyora CLI configuration."""
|
|
12
|
+
|
|
13
|
+
api_key: Optional[str] = Field(default=None, description="Zyora API key")
|
|
14
|
+
api_base: str = Field(default="https://app.zyoralabs.com", description="API base URL")
|
|
15
|
+
model: str = Field(default="Zyora-DEV-32B", description="Model to use")
|
|
16
|
+
max_tokens: int = Field(default=16384, description="Maximum tokens for response")
|
|
17
|
+
temperature: float = Field(default=0.7, description="Temperature for generation")
|
|
18
|
+
stream: bool = Field(default=True, description="Stream responses for real-time output")
|
|
19
|
+
theme: str = Field(default="monokai", description="Syntax highlighting theme")
|
|
20
|
+
|
|
21
|
+
# Context settings
|
|
22
|
+
max_context_files: int = Field(default=10, description="Max files to include in context")
|
|
23
|
+
max_file_size: int = Field(default=100000, description="Max file size to read (bytes)")
|
|
24
|
+
|
|
25
|
+
# Security scan settings
|
|
26
|
+
scan_extensions: list[str] = Field(
|
|
27
|
+
default=[".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".php", ".rb", ".c", ".cpp", ".h"],
|
|
28
|
+
description="File extensions to scan"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config_dir() -> Path:
|
|
33
|
+
"""Get the configuration directory."""
|
|
34
|
+
if os.name == "nt":
|
|
35
|
+
config_dir = Path(os.environ.get("APPDATA", "~")) / "zyora"
|
|
36
|
+
else:
|
|
37
|
+
config_dir = Path.home() / ".config" / "zyora"
|
|
38
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return config_dir
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_config_path() -> Path:
|
|
43
|
+
"""Get the configuration file path."""
|
|
44
|
+
return get_config_dir() / "config.toml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config() -> ZyoraConfig:
|
|
48
|
+
"""Load configuration from file and environment."""
|
|
49
|
+
config_path = get_config_path()
|
|
50
|
+
config_data = {}
|
|
51
|
+
|
|
52
|
+
# Load from file if exists
|
|
53
|
+
if config_path.exists():
|
|
54
|
+
try:
|
|
55
|
+
config_data = toml.load(config_path)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Override with environment variables
|
|
60
|
+
env_mappings = {
|
|
61
|
+
"ZYORA_API_KEY": "api_key",
|
|
62
|
+
"ZYORA_API_BASE": "api_base",
|
|
63
|
+
"ZYORA_MODEL": "model",
|
|
64
|
+
"ZYORA_MAX_TOKENS": "max_tokens",
|
|
65
|
+
"ZYORA_TEMPERATURE": "temperature",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for env_var, config_key in env_mappings.items():
|
|
69
|
+
if env_var in os.environ:
|
|
70
|
+
value = os.environ[env_var]
|
|
71
|
+
if config_key in ("max_tokens",):
|
|
72
|
+
value = int(value)
|
|
73
|
+
elif config_key in ("temperature",):
|
|
74
|
+
value = float(value)
|
|
75
|
+
config_data[config_key] = value
|
|
76
|
+
|
|
77
|
+
return ZyoraConfig(**config_data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_config(config: ZyoraConfig) -> None:
|
|
81
|
+
"""Save configuration to file."""
|
|
82
|
+
config_path = get_config_path()
|
|
83
|
+
config_data = config.model_dump(exclude_none=True)
|
|
84
|
+
|
|
85
|
+
with open(config_path, "w") as f:
|
|
86
|
+
toml.dump(config_data, f)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_api_key() -> Optional[str]:
|
|
90
|
+
"""Get API key from config or environment."""
|
|
91
|
+
# Check environment first
|
|
92
|
+
if "ZYORA_API_KEY" in os.environ:
|
|
93
|
+
return os.environ["ZYORA_API_KEY"]
|
|
94
|
+
|
|
95
|
+
# Check config file
|
|
96
|
+
config = load_config()
|
|
97
|
+
return config.api_key
|