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 ADDED
@@ -0,0 +1,4 @@
1
+ """Zyora CLI - AI-powered code generation and security scanning."""
2
+
3
+ __version__ = "0.1.0"
4
+ __app_name__ = "zyora"
zyora/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running zyora as a module."""
2
+
3
+ from .main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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