raze-cli 1.0.0__tar.gz

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,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: raze-cli
3
+ Version: 1.0.0
4
+ Summary: RAZE CLI - Local-first AI workforce command line interface
5
+ Author-email: Mohammed Alanazi <mtma.1@hotmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mtma1/Raze-1
8
+ Project-URL: Repository, https://github.com/mtma1/Raze-1
9
+ Project-URL: Issues, https://github.com/mtma1/Raze-1/issues
10
+ Keywords: ai,cli,agents,automation,local-first,raze
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: typer>=0.15.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: httpx>=0.28.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: grpc
25
+ Requires-Dist: grpcio>=1.78.0; extra == "grpc"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
28
+
29
+ # RAZE CLI
30
+
31
+ RAZE CLI is the local command line entrypoint for the RAZE runtime.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install raze-cli
37
+ ```
38
+
39
+ Optional gRPC transport support:
40
+
41
+ ```bash
42
+ pip install "raze-cli[grpc]"
43
+ ```
44
+
45
+ ## First run
46
+
47
+ ```bash
48
+ raze setup
49
+ raze doctor
50
+ raze dashboard
51
+ ```
52
+
53
+ ## What the CLI expects
54
+
55
+ - A writable local runtime directory via `RAZE_DATA_DIR` or the default user data path
56
+ - A reachable `raze_core` runtime when you want to use Core-backed commands
57
+ - A separate dashboard build or the dashboard source tree when you want to launch the UI
58
+ - When `grpcio` is installed and Core exposes the localhost control plane, the CLI will prefer gRPC automatically and fall back to REST when needed
59
+
60
+ ## Common commands
61
+
62
+ ```bash
63
+ raze config
64
+ raze list
65
+ raze install research-employee
66
+ raze run research-employee
67
+ raze reset-db --yes
68
+ ```
@@ -0,0 +1,40 @@
1
+ # RAZE CLI
2
+
3
+ RAZE CLI is the local command line entrypoint for the RAZE runtime.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install raze-cli
9
+ ```
10
+
11
+ Optional gRPC transport support:
12
+
13
+ ```bash
14
+ pip install "raze-cli[grpc]"
15
+ ```
16
+
17
+ ## First run
18
+
19
+ ```bash
20
+ raze setup
21
+ raze doctor
22
+ raze dashboard
23
+ ```
24
+
25
+ ## What the CLI expects
26
+
27
+ - A writable local runtime directory via `RAZE_DATA_DIR` or the default user data path
28
+ - A reachable `raze_core` runtime when you want to use Core-backed commands
29
+ - A separate dashboard build or the dashboard source tree when you want to launch the UI
30
+ - When `grpcio` is installed and Core exposes the localhost control plane, the CLI will prefer gRPC automatically and fall back to REST when needed
31
+
32
+ ## Common commands
33
+
34
+ ```bash
35
+ raze config
36
+ raze list
37
+ raze install research-employee
38
+ raze run research-employee
39
+ raze reset-db --yes
40
+ ```
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "raze-cli"
3
+ version = "1.0.0"
4
+ description = "RAZE CLI - Local-first AI workforce command line interface"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Mohammed Alanazi", email = "mtma.1@hotmail.com" },
11
+ ]
12
+ keywords = ["ai", "cli", "agents", "automation", "local-first", "raze"]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: Microsoft :: Windows",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ ]
22
+ dependencies = [
23
+ "typer>=0.15.0",
24
+ "rich>=13.0.0",
25
+ "httpx>=0.28.0",
26
+ "pyyaml>=6.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ grpc = [
31
+ "grpcio>=1.78.0",
32
+ ]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ raze = "raze_cli.main:app"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/mtma1/Raze-1"
42
+ Repository = "https://github.com/mtma1/Raze-1"
43
+ Issues = "https://github.com/mtma1/Raze-1/issues"
44
+
45
+ [build-system]
46
+ requires = ["setuptools>=75.0"]
47
+ build-backend = "setuptools.build_meta"
48
+
49
+ [tool.setuptools]
50
+ include-package-data = true
51
+
52
+ [tool.setuptools.packages.find]
53
+ include = ["raze_cli*"]
@@ -0,0 +1,3 @@
1
+ """RAZE CLI - Local AI workforce platform."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,233 @@
1
+ """HTTP client for communicating with the local RAZE Core API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import httpx
12
+
13
+ from raze_cli.grpc_client import RazeGrpcClient
14
+
15
+
16
+ DEFAULT_CORE_URL = "http://127.0.0.1:52100"
17
+ FALLBACK_CORE_PORTS = [8400, 8401, 8500, 9100, 9200]
18
+
19
+
20
+ class RazeAPIClient:
21
+ """Client for RAZE Core API."""
22
+
23
+ def __init__(self, base_url: str = DEFAULT_CORE_URL):
24
+ self.base_url = base_url.rstrip("/")
25
+ self.grpc = RazeGrpcClient()
26
+
27
+ def _url(self, path: str) -> str:
28
+ return f"{self.base_url}{path}"
29
+
30
+ def _runtime_state_path(self) -> Path:
31
+ data_dir = Path(os.environ.get("RAZE_DATA_DIR", Path.home() / ".raze"))
32
+ return data_dir / "runtime.json"
33
+
34
+ def _load_runtime_target(self) -> Optional[str]:
35
+ path = self._runtime_state_path()
36
+ if not path.exists():
37
+ return None
38
+
39
+ try:
40
+ payload = json.loads(path.read_text(encoding="utf-8"))
41
+ except Exception:
42
+ return None
43
+
44
+ host = payload.get("core_host") or "127.0.0.1"
45
+ port = payload.get("core_port")
46
+ if isinstance(port, int) and port > 0:
47
+ return f"http://{host}:{port}"
48
+ if isinstance(port, str) and port.isdigit():
49
+ return f"http://{host}:{int(port)}"
50
+ return None
51
+
52
+ def _candidate_ports(self, preferred_port: int, scan_range: int) -> list[int]:
53
+ candidates: list[int] = []
54
+
55
+ runtime_target = self._load_runtime_target()
56
+ if runtime_target:
57
+ try:
58
+ candidates.append(int(runtime_target.rsplit(":", 1)[-1]))
59
+ except ValueError:
60
+ pass
61
+
62
+ for offset in range(scan_range + 1):
63
+ candidates.append(preferred_port + offset)
64
+
65
+ candidates.extend(FALLBACK_CORE_PORTS)
66
+
67
+ deduped: list[int] = []
68
+ seen: set[int] = set()
69
+ for port in candidates:
70
+ if port not in seen:
71
+ seen.add(port)
72
+ deduped.append(port)
73
+ return deduped
74
+
75
+ async def health(self) -> dict:
76
+ async with httpx.AsyncClient(timeout=5.0) as client:
77
+ resp = await client.get(self._url("/api/health"))
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+
81
+ async def is_running(self) -> bool:
82
+ try:
83
+ await self.health()
84
+ return True
85
+ except Exception:
86
+ return False
87
+
88
+ async def discover_core(self, preferred_port: int = 52100, scan_range: int = 20) -> Optional[int]:
89
+ """Discover a running Core instance using runtime state plus port scanning."""
90
+ runtime_target = self._load_runtime_target()
91
+ if runtime_target:
92
+ try:
93
+ async with httpx.AsyncClient(timeout=2.0) as client:
94
+ resp = await client.get(f"{runtime_target}/api/health")
95
+ if resp.status_code == 200 and resp.json().get("status") == "healthy":
96
+ self.base_url = runtime_target
97
+ return int(runtime_target.rsplit(":", 1)[-1])
98
+ except Exception:
99
+ pass
100
+
101
+ for port in self._candidate_ports(preferred_port, scan_range):
102
+ try:
103
+ url = f"http://127.0.0.1:{port}"
104
+ async with httpx.AsyncClient(timeout=2.0) as client:
105
+ resp = await client.get(f"{url}/api/health")
106
+ if resp.status_code == 200 and resp.json().get("status") == "healthy":
107
+ self.base_url = url
108
+ payload = resp.json()
109
+ return int(payload.get("port") or port)
110
+ except Exception:
111
+ continue
112
+ return None
113
+
114
+ async def wait_for_ready(self, timeout: int = 15) -> bool:
115
+ """Poll the health endpoint until Core is ready or timeout."""
116
+ for _ in range(timeout * 2):
117
+ try:
118
+ port = await self.discover_core()
119
+ if port is not None:
120
+ return True
121
+ except Exception:
122
+ pass
123
+ await asyncio.sleep(0.5)
124
+ return False
125
+
126
+ async def list_providers(self) -> list[dict]:
127
+ if self.grpc.available():
128
+ try:
129
+ return await self.grpc.list_providers()
130
+ except Exception:
131
+ pass
132
+ async with httpx.AsyncClient(timeout=10.0) as client:
133
+ resp = await client.get(self._url("/api/providers"))
134
+ resp.raise_for_status()
135
+ return resp.json()
136
+
137
+ async def add_provider(self, data: dict) -> dict:
138
+ async with httpx.AsyncClient(timeout=10.0) as client:
139
+ resp = await client.post(self._url("/api/providers"), json=data)
140
+ resp.raise_for_status()
141
+ return resp.json()
142
+
143
+ async def test_provider(self, provider_id: str) -> dict:
144
+ if self.grpc.available():
145
+ try:
146
+ return await self.grpc.test_provider(provider_id)
147
+ except Exception:
148
+ pass
149
+ async with httpx.AsyncClient(timeout=20.0) as client:
150
+ resp = await client.post(self._url(f"/api/providers/{provider_id}/test"))
151
+ resp.raise_for_status()
152
+ return resp.json()
153
+
154
+ async def list_employees(self) -> list[dict]:
155
+ async with httpx.AsyncClient(timeout=10.0) as client:
156
+ resp = await client.get(self._url("/api/employees"))
157
+ resp.raise_for_status()
158
+ return resp.json()
159
+
160
+ async def install_employee(self, employee_id: str, dev_path: Optional[str] = None) -> dict:
161
+ data = {"employee_id": employee_id}
162
+ if dev_path:
163
+ data["dev_path"] = dev_path
164
+ async with httpx.AsyncClient(timeout=120.0) as client:
165
+ resp = await client.post(self._url("/api/employees/install"), json=data)
166
+ resp.raise_for_status()
167
+ return resp.json()
168
+
169
+ async def run_employee(self, employee_id: str) -> dict:
170
+ async with httpx.AsyncClient(timeout=30.0) as client:
171
+ resp = await client.post(self._url(f"/api/employees/{employee_id}/run"))
172
+ resp.raise_for_status()
173
+ return resp.json()
174
+
175
+ async def stop_employee(self, employee_id: str) -> dict:
176
+ async with httpx.AsyncClient(timeout=30.0) as client:
177
+ resp = await client.post(self._url(f"/api/employees/{employee_id}/stop"))
178
+ resp.raise_for_status()
179
+ return resp.json()
180
+
181
+ async def uninstall_employee(self, employee_id: str) -> dict:
182
+ async with httpx.AsyncClient(timeout=30.0) as client:
183
+ resp = await client.delete(self._url(f"/api/employees/{employee_id}"))
184
+ resp.raise_for_status()
185
+ return resp.json()
186
+
187
+ async def list_registry(self) -> list[dict]:
188
+ async with httpx.AsyncClient(timeout=10.0) as client:
189
+ resp = await client.get(self._url("/api/registry"))
190
+ resp.raise_for_status()
191
+ return resp.json()
192
+
193
+ async def run_doctor(self) -> dict:
194
+ if self.grpc.available():
195
+ try:
196
+ return await self.grpc.run_doctor()
197
+ except Exception:
198
+ pass
199
+ async with httpx.AsyncClient(timeout=30.0) as client:
200
+ resp = await client.get(self._url("/api/doctor"))
201
+ resp.raise_for_status()
202
+ return resp.json()
203
+
204
+ async def get_logs(self, limit: int = 50, level: Optional[str] = None) -> list[dict]:
205
+ params = {"limit": limit}
206
+ if level:
207
+ params["level"] = level
208
+ async with httpx.AsyncClient(timeout=10.0) as client:
209
+ resp = await client.get(self._url("/api/logs"), params=params)
210
+ resp.raise_for_status()
211
+ return resp.json()
212
+
213
+ async def get_settings(self) -> dict:
214
+ if self.grpc.available():
215
+ try:
216
+ return await self.grpc.get_settings()
217
+ except Exception:
218
+ pass
219
+ async with httpx.AsyncClient(timeout=10.0) as client:
220
+ resp = await client.get(self._url("/api/settings"))
221
+ resp.raise_for_status()
222
+ return resp.json()
223
+
224
+ async def list_models(self) -> dict:
225
+ if self.grpc.available():
226
+ try:
227
+ return await self.grpc.list_models()
228
+ except Exception:
229
+ pass
230
+ async with httpx.AsyncClient(timeout=10.0) as client:
231
+ resp = await client.get(self._url("/api/models"))
232
+ resp.raise_for_status()
233
+ return resp.json()
@@ -0,0 +1,104 @@
1
+ """Optional gRPC client for the RAZE internal control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+
11
+ SYSTEM_SERVICE = "raze.v1.SystemService"
12
+ PROVIDERS_SERVICE = "raze.v1.ProvidersService"
13
+ JOBS_SERVICE = "raze.v1.JobsService"
14
+ ACTIONS_SERVICE = "raze.v1.ActionsService"
15
+
16
+
17
+ def _method_path(service: str, method: str) -> str:
18
+ return f"/{service}/{method}"
19
+
20
+
21
+ def _dumps(payload: Any) -> bytes:
22
+ return json.dumps(payload or {}, ensure_ascii=False, sort_keys=True).encode("utf-8")
23
+
24
+
25
+ def _loads(payload: bytes | None) -> Any:
26
+ if not payload:
27
+ return {}
28
+ return json.loads(payload.decode("utf-8"))
29
+
30
+
31
+ class RazeGrpcClient:
32
+ def __init__(self):
33
+ self._grpc = self._import_grpc()
34
+
35
+ def _import_grpc(self):
36
+ try:
37
+ import grpc
38
+ except Exception:
39
+ return None
40
+ return grpc
41
+
42
+ def _runtime_state_path(self) -> Path:
43
+ data_dir = Path(os.environ.get("RAZE_DATA_DIR", Path.home() / ".raze"))
44
+ return data_dir / "runtime.json"
45
+
46
+ def _load_target(self) -> Optional[str]:
47
+ if self._grpc is None:
48
+ return None
49
+
50
+ path = self._runtime_state_path()
51
+ if not path.exists():
52
+ return None
53
+
54
+ try:
55
+ payload = json.loads(path.read_text(encoding="utf-8"))
56
+ except Exception:
57
+ return None
58
+
59
+ status = str(payload.get("grpc_status") or "").lower()
60
+ if status not in {"running", "ready"}:
61
+ return None
62
+
63
+ host = payload.get("grpc_host") or payload.get("core_host") or "127.0.0.1"
64
+ port = payload.get("grpc_port")
65
+ if isinstance(port, int) and port > 0:
66
+ return f"{host}:{port}"
67
+ if isinstance(port, str) and port.isdigit():
68
+ return f"{host}:{int(port)}"
69
+ return None
70
+
71
+ def available(self) -> bool:
72
+ return self._load_target() is not None
73
+
74
+ async def _call(self, path: str, payload: dict[str, Any] | None = None) -> Any:
75
+ if self._grpc is None:
76
+ raise RuntimeError("grpcio is not installed")
77
+
78
+ target = self._load_target()
79
+ if not target:
80
+ raise RuntimeError("RAZE gRPC control plane is not available")
81
+
82
+ async with self._grpc.aio.insecure_channel(target) as channel:
83
+ method = channel.unary_unary(
84
+ path,
85
+ request_serializer=_dumps,
86
+ response_deserializer=_loads,
87
+ )
88
+ return await method(payload or {})
89
+
90
+ async def get_settings(self) -> dict[str, Any]:
91
+ return await self._call(_method_path(SYSTEM_SERVICE, "GetSettings"))
92
+
93
+ async def run_doctor(self) -> dict[str, Any]:
94
+ return await self._call(_method_path(SYSTEM_SERVICE, "RunDoctor"))
95
+
96
+ async def list_providers(self) -> list[dict[str, Any]]:
97
+ payload = await self._call(_method_path(PROVIDERS_SERVICE, "ListProviders"))
98
+ return payload.get("providers", [])
99
+
100
+ async def test_provider(self, provider_id: str) -> dict[str, Any]:
101
+ return await self._call(_method_path(PROVIDERS_SERVICE, "TestProvider"), {"provider_id": provider_id})
102
+
103
+ async def list_models(self) -> dict[str, Any]:
104
+ return await self._call(_method_path(PROVIDERS_SERVICE, "ListModels"))
@@ -0,0 +1,618 @@
1
+ """RAZE CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib.util
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import webbrowser
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import httpx
17
+ import typer
18
+ import yaml
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.progress import Progress, SpinnerColumn, TextColumn
22
+ from rich.prompt import Confirm, Prompt
23
+ from rich.table import Table
24
+
25
+ from raze_cli.api_client import RazeAPIClient
26
+
27
+
28
+ app = typer.Typer(
29
+ name="raze",
30
+ help="RAZE - Local-First AI Workforce Platform",
31
+ no_args_is_help=True,
32
+ )
33
+ console = Console()
34
+ client = RazeAPIClient()
35
+
36
+
37
+ def get_raze_dir() -> Path:
38
+ return Path(os.environ.get("RAZE_DATA_DIR", Path.home() / ".raze"))
39
+
40
+
41
+ def get_db_path() -> Path:
42
+ return get_raze_dir() / "raze.db"
43
+
44
+
45
+ def get_runtime_path() -> Path:
46
+ return get_raze_dir() / "runtime.json"
47
+
48
+
49
+ def get_workspace_root() -> Path:
50
+ return Path(__file__).resolve().parent.parent.parent
51
+
52
+
53
+ def get_source_tree_root() -> Optional[Path]:
54
+ candidate = get_workspace_root()
55
+ if (candidate / "services" / "core").exists() and (candidate / "apps" / "dashboard").exists():
56
+ return candidate
57
+ return None
58
+
59
+
60
+ async def _dashboard_running(port: int) -> bool:
61
+ try:
62
+ async with httpx.AsyncClient(timeout=2.0) as http:
63
+ response = await http.get(f"http://127.0.0.1:{port}")
64
+ return response.status_code < 500
65
+ except Exception:
66
+ return False
67
+
68
+
69
+ def _render_dashboard_command(port: int = 52000) -> Optional[str]:
70
+ source_root = get_source_tree_root()
71
+ if not source_root:
72
+ return None
73
+ dashboard_dir = source_root / "apps" / "dashboard"
74
+ return f"cd {dashboard_dir} && npm run dev -- --port {port}"
75
+
76
+
77
+ def _can_launch_installed_core() -> bool:
78
+ return importlib.util.find_spec("raze_core.main") is not None
79
+
80
+
81
+ def _docker_local_status() -> tuple[str, str]:
82
+ docker_path = shutil.which("docker")
83
+ if not docker_path:
84
+ return (
85
+ "fail",
86
+ "Docker Desktop is not installed. Install Docker Desktop for Windows before running Docker-backed employees.",
87
+ )
88
+
89
+ try:
90
+ result = subprocess.run(
91
+ ["docker", "info"],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=5,
95
+ check=False,
96
+ )
97
+ except Exception as exc:
98
+ return ("warn", f"Docker is installed but could not be checked: {exc}")
99
+
100
+ if result.returncode == 0:
101
+ return ("pass", f"Docker Desktop is running ({docker_path})")
102
+
103
+ stderr = (result.stderr or result.stdout or "").strip()
104
+ lowered = stderr.lower()
105
+ if "createfile" in lowered or "pipe/docker_engine" in lowered or "npipe" in lowered:
106
+ return (
107
+ "warn",
108
+ "Docker Desktop is installed, but the Docker engine pipe is unavailable. Start Docker Desktop and wait for 'Engine running'.",
109
+ )
110
+ if "cannot connect to the docker daemon" in lowered or "is the docker daemon running" in lowered:
111
+ return (
112
+ "warn",
113
+ "Docker is installed, but the Docker engine is not running. Start Docker Desktop and retry.",
114
+ )
115
+ return ("warn", f"Docker is installed but not ready: {stderr or 'unknown error'}")
116
+
117
+
118
+ @app.command()
119
+ def setup():
120
+ """First-time setup: configure your RAZE environment."""
121
+ console.print(Panel.fit(
122
+ "[bold cyan]RAZE[/bold cyan] - First-time setup",
123
+ border_style="cyan",
124
+ ))
125
+
126
+ raze_dir = get_raze_dir()
127
+ raze_dir.mkdir(parents=True, exist_ok=True)
128
+ console.print(f" Data directory: [dim]{raze_dir}[/dim]")
129
+
130
+ config_path = raze_dir / "config.yaml"
131
+ if config_path.exists():
132
+ if Confirm.ask(" Config already exists. Overwrite?", default=False):
133
+ _create_config(config_path)
134
+ else:
135
+ console.print(" Keeping existing config.")
136
+ else:
137
+ _create_config(config_path)
138
+
139
+ console.print()
140
+ if Confirm.ask(" Open the local dashboard after setup?", default=True):
141
+ dashboard()
142
+
143
+
144
+ def _create_config(config_path: Path):
145
+ console.print()
146
+ console.print(" Configure your LLM provider:")
147
+ console.print(" 1. OpenAI")
148
+ console.print(" 2. Ollama (local)")
149
+ console.print(" 3. Anthropic")
150
+ console.print(" 4. Google Gemini")
151
+ console.print(" 5. Groq")
152
+ console.print(" 6. xAI")
153
+ console.print(" 7. Mistral")
154
+ console.print(" 8. OpenAI-compatible endpoint")
155
+ console.print(" 9. Custom endpoint")
156
+
157
+ choice = Prompt.ask(" Select provider", choices=["1", "2", "3", "4", "5", "6", "7", "8", "9"], default="1")
158
+
159
+ if choice == "1":
160
+ provider_type = "openai"
161
+ name = Prompt.ask(" Provider name", default="OpenAI")
162
+ base_url = Prompt.ask(" Base URL", default="https://api.openai.com/v1")
163
+ api_key = Prompt.ask(" API key", password=True)
164
+ model = Prompt.ask(" Model", default="gpt-4o")
165
+ elif choice == "2":
166
+ provider_type = "ollama"
167
+ name = "Ollama"
168
+ base_url = Prompt.ask(" Ollama URL", default="http://localhost:11434")
169
+ api_key = None
170
+ model = Prompt.ask(" Model", default="qwen3.5:9b")
171
+ elif choice == "3":
172
+ provider_type = "anthropic"
173
+ name = Prompt.ask(" Provider name", default="Anthropic")
174
+ base_url = Prompt.ask(" Base URL", default="https://api.anthropic.com/v1")
175
+ api_key = Prompt.ask(" API key", password=True)
176
+ model = Prompt.ask(" Model", default="claude-sonnet-4-6")
177
+ elif choice == "4":
178
+ provider_type = "gemini"
179
+ name = Prompt.ask(" Provider name", default="Google Gemini")
180
+ base_url = Prompt.ask(" Base URL", default="https://generativelanguage.googleapis.com/v1beta")
181
+ api_key = Prompt.ask(" API key", password=True)
182
+ model = Prompt.ask(" Model", default="gemini-3-flash-preview")
183
+ elif choice == "5":
184
+ provider_type = "groq"
185
+ name = Prompt.ask(" Provider name", default="Groq")
186
+ base_url = Prompt.ask(" Base URL", default="https://api.groq.com/openai/v1")
187
+ api_key = Prompt.ask(" API key", password=True)
188
+ model = Prompt.ask(" Model", default="llama-3.1-8b-instant")
189
+ elif choice == "6":
190
+ provider_type = "xai"
191
+ name = Prompt.ask(" Provider name", default="xAI")
192
+ base_url = Prompt.ask(" Base URL", default="https://api.x.ai/v1")
193
+ api_key = Prompt.ask(" API key", password=True)
194
+ model = Prompt.ask(" Model", default="grok-4-1-fast-non-reasoning")
195
+ elif choice == "7":
196
+ provider_type = "mistral"
197
+ name = Prompt.ask(" Provider name", default="Mistral")
198
+ base_url = Prompt.ask(" Base URL", default="https://api.mistral.ai/v1")
199
+ api_key = Prompt.ask(" API key", password=True)
200
+ model = Prompt.ask(" Model", default="mistral-large-latest")
201
+ elif choice == "8":
202
+ provider_type = "openai_compatible"
203
+ name = Prompt.ask(" Provider name", default="OpenAI-compatible")
204
+ base_url = Prompt.ask(" Base URL", default="https://example.com/v1")
205
+ api_key = Prompt.ask(" API key", password=True)
206
+ model = Prompt.ask(" Model", default="model-name")
207
+ else:
208
+ provider_type = "custom"
209
+ name = Prompt.ask(" Provider name", default="Custom")
210
+ base_url = Prompt.ask(" Base URL")
211
+ api_key = Prompt.ask(" API key (optional)", password=True, default="") or None
212
+ model = Prompt.ask(" Model")
213
+
214
+ provider_id = name.lower().replace(" ", "-")
215
+
216
+ # Validate URL format before persisting
217
+ base_url = base_url.strip().rstrip("/")
218
+ if not base_url.startswith(("http://", "https://")):
219
+ console.print(f" [yellow]Warning: Base URL '{base_url}' does not start with http:// or https://[/yellow]")
220
+ console.print(" [yellow]This may cause connection failures. Please verify the URL.[/yellow]")
221
+
222
+ config = {
223
+ "providers": [{
224
+ "id": provider_id,
225
+ "name": name,
226
+ "type": provider_type,
227
+ "base_url": base_url,
228
+ "api_key": api_key,
229
+ "model": model,
230
+ "is_default": True,
231
+ }],
232
+ "default_provider": provider_id,
233
+ "fallback_provider": None,
234
+ "core_version": "1.0.0",
235
+ }
236
+
237
+ with open(config_path, "w", encoding="utf-8") as handle:
238
+ yaml.dump(config, handle, default_flow_style=False)
239
+
240
+ console.print(f" Config saved to [dim]{config_path}[/dim]")
241
+
242
+
243
+ @app.command()
244
+ def dashboard():
245
+ """Ensure Core is running, then open the local dashboard URL."""
246
+ console.print(Panel.fit(
247
+ "[bold magenta]RAZE[/bold magenta] - Dashboard launcher",
248
+ border_style="magenta",
249
+ ))
250
+
251
+ console.print(" Checking for a running Core instance...")
252
+ discovered_port = asyncio.run(client.discover_core())
253
+
254
+ if discovered_port:
255
+ console.print(f" Core found on [cyan]{discovered_port}[/cyan]")
256
+ else:
257
+ if not _can_launch_installed_core():
258
+ console.print(" [red]Core is not running and raze-core is not installed in this environment.[/red]")
259
+ source_root = get_source_tree_root()
260
+ if source_root:
261
+ console.print(f" [dim]Run manually: cd {source_root / 'services' / 'core'} && python -m raze_core.main[/dim]")
262
+ else:
263
+ console.print(" [dim]Install the runtime package, then run: python -m raze_core.main[/dim]")
264
+ raise typer.Exit(1)
265
+
266
+ console.print(" Core is not running. Starting it in the background...")
267
+ source_root = get_source_tree_root()
268
+ core_dir = source_root / "services" / "core" if source_root else None
269
+ try:
270
+ command = [sys.executable, "-m", "raze_core.main"]
271
+ if sys.platform == "win32":
272
+ creation_flags = subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS
273
+ proc = subprocess.Popen(
274
+ command,
275
+ cwd=str(core_dir) if core_dir else None,
276
+ env=os.environ.copy(),
277
+ stdout=subprocess.DEVNULL,
278
+ stderr=subprocess.DEVNULL,
279
+ creationflags=creation_flags,
280
+ )
281
+ else:
282
+ proc = subprocess.Popen(
283
+ command,
284
+ cwd=str(core_dir) if core_dir else None,
285
+ env=os.environ.copy(),
286
+ stdout=subprocess.DEVNULL,
287
+ stderr=subprocess.DEVNULL,
288
+ start_new_session=True,
289
+ )
290
+ console.print(f" Core process started (PID {proc.pid}). Waiting for readiness...")
291
+ except Exception as exc:
292
+ console.print(f" [red]Failed to start Core: {exc}[/red]")
293
+ if core_dir:
294
+ console.print(f" [dim]Run manually: cd {core_dir} && python -m raze_core.main[/dim]")
295
+ else:
296
+ console.print(" [dim]Run manually: python -m raze_core.main[/dim]")
297
+ raise typer.Exit(1)
298
+
299
+ with Progress(
300
+ SpinnerColumn(),
301
+ TextColumn("[progress.description]{task.description}"),
302
+ console=console,
303
+ ) as progress:
304
+ task = progress.add_task(" Waiting for Core to become ready...", total=None)
305
+ ready = asyncio.run(client.wait_for_ready(timeout=20))
306
+ if not ready:
307
+ progress.update(task, description=" Core did not respond in time")
308
+ console.print(" [yellow]Core may still be starting. Check the Core logs directly.[/yellow]")
309
+ if core_dir:
310
+ console.print(f" [dim]Run manually: cd {core_dir} && python -m raze_core.main[/dim]")
311
+ else:
312
+ console.print(" [dim]Run manually: python -m raze_core.main[/dim]")
313
+ raise typer.Exit(1)
314
+ progress.update(task, description=" Core is ready")
315
+
316
+ discovered_port = asyncio.run(client.discover_core())
317
+
318
+ if not discovered_port:
319
+ console.print(" [red]Could not determine the active Core port.[/red]")
320
+ raise typer.Exit(1)
321
+
322
+ settings = None
323
+ try:
324
+ settings = asyncio.run(client.get_settings())
325
+ console.print(f" Core API: [cyan]{settings['core_url']}[/cyan]")
326
+ if settings["core_port"] != settings["preferred_core_port"]:
327
+ console.print(
328
+ f" [yellow]Preferred port {settings['preferred_core_port']} was unavailable. "
329
+ f"Core is using {settings['core_port']} instead.[/yellow]"
330
+ )
331
+ console.print(f" Database: [dim]{settings['db_path']}[/dim]")
332
+ except Exception:
333
+ console.print(f" Core API: [cyan]http://127.0.0.1:{discovered_port}[/cyan]")
334
+
335
+ dashboard_port = int(settings["dashboard_port"]) if settings else 52000
336
+ dashboard_url = f"http://localhost:{dashboard_port}"
337
+ dashboard_running = asyncio.run(_dashboard_running(dashboard_port))
338
+
339
+ if not dashboard_running:
340
+ console.print(f" [yellow]Dashboard dev server is not running on port {dashboard_port}.[/yellow]")
341
+ dashboard_command = _render_dashboard_command(dashboard_port)
342
+ if dashboard_command:
343
+ console.print(" Start it in a new terminal:")
344
+ console.print(f" [dim]{dashboard_command}[/dim]")
345
+ console.print(" The dashboard proxy reads the active Core port from the RAZE runtime state.")
346
+ else:
347
+ console.print(" [yellow]This CLI package does not include the dashboard dev server.[/yellow]")
348
+ console.print(" Start the dashboard from the source tree or open a packaged dashboard build if you distribute one separately.")
349
+ return
350
+
351
+ console.print(f" Opening dashboard: [bold cyan]{dashboard_url}[/bold cyan]")
352
+ webbrowser.open(dashboard_url)
353
+
354
+
355
+ @app.command()
356
+ def install(
357
+ employee: str = typer.Argument(..., help="Employee ID to install"),
358
+ dev: Optional[str] = typer.Option(None, "--dev", help="Local path for dev mode install"),
359
+ ):
360
+ """Install an employee from the curated registry."""
361
+ with Progress(
362
+ SpinnerColumn(),
363
+ TextColumn("[progress.description]{task.description}"),
364
+ console=console,
365
+ ) as progress:
366
+ task = progress.add_task(f"Installing {employee}...", total=None)
367
+ try:
368
+ result = asyncio.run(client.install_employee(employee, dev))
369
+ progress.update(task, description=f"Installed {result['name']} v{result['version']}")
370
+ except Exception as exc:
371
+ progress.update(task, description=f"Failed: {exc}")
372
+ raise typer.Exit(1)
373
+
374
+
375
+ @app.command()
376
+ def run(employee: str = typer.Argument(..., help="Employee ID to run")):
377
+ """Start a Docker-backed employee."""
378
+ with Progress(
379
+ SpinnerColumn(),
380
+ TextColumn("[progress.description]{task.description}"),
381
+ console=console,
382
+ ) as progress:
383
+ task = progress.add_task(f"Starting {employee}...", total=None)
384
+ try:
385
+ result = asyncio.run(client.run_employee(employee))
386
+ progress.update(task, description=f"{result['name']} is {result['status']} on port {result.get('port', '?')}")
387
+ except Exception as exc:
388
+ progress.update(task, description=f"Failed: {exc}")
389
+ raise typer.Exit(1)
390
+
391
+
392
+ @app.command()
393
+ def stop(employee: str = typer.Argument(..., help="Employee ID to stop")):
394
+ """Stop a running employee."""
395
+ try:
396
+ result = asyncio.run(client.stop_employee(employee))
397
+ console.print(f" Stopped {result['name']}")
398
+ except Exception as exc:
399
+ console.print(f" Failed: {exc}")
400
+ raise typer.Exit(1)
401
+
402
+
403
+ @app.command(name="list")
404
+ def list_cmd(
405
+ registry: bool = typer.Option(False, "--registry", help="Show available employees from the registry"),
406
+ ):
407
+ """List installed employees or browse the registry."""
408
+ if registry:
409
+ try:
410
+ entries = asyncio.run(client.list_registry())
411
+ except Exception as exc:
412
+ console.print(f" Failed to connect to Core: {exc}")
413
+ raise typer.Exit(1)
414
+
415
+ table = Table(title="RAZE Employee Registry")
416
+ table.add_column("ID", style="cyan")
417
+ table.add_column("Name")
418
+ table.add_column("Version")
419
+ table.add_column("Installed", justify="center")
420
+
421
+ for entry in entries:
422
+ table.add_row(
423
+ entry["id"],
424
+ entry["name"],
425
+ entry["version"],
426
+ "yes" if entry.get("installed") else "",
427
+ )
428
+ console.print(table)
429
+ return
430
+
431
+ try:
432
+ employees = asyncio.run(client.list_employees())
433
+ except Exception as exc:
434
+ console.print(f" Failed to connect to Core: {exc}")
435
+ raise typer.Exit(1)
436
+
437
+ if not employees:
438
+ console.print(" No employees installed. Run [cyan]raze install <employee>[/cyan]")
439
+ return
440
+
441
+ table = Table(title="Installed Employees")
442
+ table.add_column("ID", style="cyan")
443
+ table.add_column("Name")
444
+ table.add_column("Version")
445
+ table.add_column("Status")
446
+ table.add_column("Port", justify="right")
447
+
448
+ for employee in employees:
449
+ table.add_row(
450
+ employee["id"],
451
+ employee["name"],
452
+ employee["version"],
453
+ employee["status"],
454
+ str(employee.get("port", "")),
455
+ )
456
+ console.print(table)
457
+
458
+
459
+ @app.command()
460
+ def doctor():
461
+ """Run system diagnostics."""
462
+ console.print(Panel.fit(
463
+ "[bold cyan]RAZE[/bold cyan] Doctor",
464
+ border_style="cyan",
465
+ ))
466
+
467
+ try:
468
+ report = asyncio.run(client.run_doctor())
469
+ except Exception:
470
+ console.print(" Core is not running. Running local checks only...")
471
+ _run_local_doctor()
472
+ return
473
+
474
+ overall = report["overall"]
475
+ color = {"healthy": "green", "degraded": "yellow", "unhealthy": "red"}.get(overall, "white")
476
+ console.print(f" Overall: [{color}]{overall.upper()}[/{color}]")
477
+ console.print()
478
+
479
+ for check in report["checks"]:
480
+ icon = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}.get(check["status"], "?")
481
+ tone = {"pass": "green", "warn": "yellow", "fail": "red"}.get(check["status"], "white")
482
+ console.print(f" [{tone}]{icon}[/{tone}] {check['name']}: {check['message']}")
483
+ if check.get("details"):
484
+ console.print(f" [dim]{check['details']}[/dim]")
485
+
486
+
487
+ def _run_local_doctor():
488
+ console.print(f" Python: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
489
+ console.print(f" Platform: {sys.platform}")
490
+
491
+ docker_status, docker_message = _docker_local_status()
492
+ docker_color = {"pass": "green", "warn": "yellow", "fail": "red"}.get(docker_status, "white")
493
+ console.print(f" [{docker_color}]Docker:[/{docker_color}] {docker_message}")
494
+
495
+ config_path = get_raze_dir() / "config.yaml"
496
+ if config_path.exists():
497
+ console.print(f" Config: {config_path}")
498
+ else:
499
+ console.print(" Config: not found. Run [cyan]raze setup[/cyan]")
500
+
501
+ console.print(f" Database: {get_db_path()}")
502
+ runtime_path = get_runtime_path()
503
+ if runtime_path.exists():
504
+ console.print(f" Runtime state: {runtime_path}")
505
+
506
+
507
+ @app.command()
508
+ def update():
509
+ """Check for RAZE updates."""
510
+ console.print(" Checking for updates...")
511
+ console.print(" You are running RAZE v1.0.0")
512
+
513
+
514
+ @app.command()
515
+ def config():
516
+ """Show current local RAZE configuration paths and provider settings."""
517
+ config_path = get_raze_dir() / "config.yaml"
518
+ console.print(Panel.fit(
519
+ "[bold cyan]RAZE[/bold cyan] Configuration",
520
+ border_style="cyan",
521
+ ))
522
+
523
+ if not config_path.exists():
524
+ console.print(" No configuration found. Run [cyan]raze setup[/cyan]")
525
+ raise typer.Exit(1)
526
+
527
+ with open(config_path, encoding="utf-8") as handle:
528
+ data = yaml.safe_load(handle) or {}
529
+
530
+ for provider in data.get("providers", []):
531
+ console.print(f" Provider: [cyan]{provider['name']}[/cyan] ({provider['type']})")
532
+ console.print(f" Base URL: {provider['base_url']}")
533
+ console.print(f" Model: {provider['model']}")
534
+ console.print(f" API Key: {'configured' if provider.get('api_key') else 'not set'}")
535
+
536
+ console.print(f"\n Config file: [dim]{config_path}[/dim]")
537
+ console.print(f" Data dir: [dim]{get_raze_dir()}[/dim]")
538
+ console.print(f" Database: [dim]{get_db_path()}[/dim]")
539
+ console.print(f" Runtime state: [dim]{get_runtime_path()}[/dim]")
540
+
541
+
542
+ @app.command()
543
+ def status():
544
+ """Show the current local runtime status and important paths."""
545
+ console.print(Panel.fit(
546
+ "[bold cyan]RAZE[/bold cyan] Status",
547
+ border_style="cyan",
548
+ ))
549
+
550
+ raze_dir = get_raze_dir()
551
+ runtime_path = get_runtime_path()
552
+ console.print(f" Data dir: [dim]{raze_dir}[/dim]")
553
+ console.print(f" Config: [dim]{raze_dir / 'config.yaml'}[/dim]")
554
+ console.print(f" Database: [dim]{get_db_path()}[/dim]")
555
+ console.print(f" Runtime state: [dim]{runtime_path}[/dim]")
556
+
557
+ docker_status, docker_message = _docker_local_status()
558
+ docker_color = {"pass": "green", "warn": "yellow", "fail": "red"}.get(docker_status, "white")
559
+ console.print(f" [{docker_color}]Docker:[/{docker_color}] {docker_message}")
560
+
561
+ discovered_port = asyncio.run(client.discover_core())
562
+ if discovered_port:
563
+ console.print(f" Core: [green]running[/green] on [cyan]{client.base_url}[/cyan]")
564
+ try:
565
+ settings = asyncio.run(client.get_settings())
566
+ console.print(f" Dashboard port: [cyan]{settings['dashboard_port']}[/cyan]")
567
+ console.print(f" gRPC status: [dim]{settings.get('grpc_status', 'disabled')}[/dim]")
568
+ except Exception as exc:
569
+ console.print(f" [yellow]Core is reachable but settings lookup failed: {exc}[/yellow]")
570
+ else:
571
+ console.print(" [yellow]Core is not currently responding.[/yellow]")
572
+ source_root = get_source_tree_root()
573
+ if source_root:
574
+ console.print(f" [dim]Manual Core start: cd {source_root / 'services' / 'core'} && python -m raze_core.main[/dim]")
575
+
576
+ dashboard_command = _render_dashboard_command()
577
+ if dashboard_command:
578
+ console.print(f" [dim]Dashboard dev command: {dashboard_command}[/dim]")
579
+
580
+
581
+ @app.command("reset-db")
582
+ def reset_db(
583
+ yes: bool = typer.Option(False, "--yes", help="Reset without interactive confirmation"),
584
+ backup: bool = typer.Option(True, "--backup/--no-backup", help="Backup the existing database first"),
585
+ ):
586
+ """Dev-safe local database reset."""
587
+ db_path = get_db_path()
588
+ if not db_path.exists():
589
+ console.print(f" Database does not exist yet: [dim]{db_path}[/dim]")
590
+ return
591
+
592
+ if not yes:
593
+ confirmed = Confirm.ask(
594
+ f" Reset the local database at {db_path}? This is intended for disposable dev data only.",
595
+ default=False,
596
+ )
597
+ if not confirmed:
598
+ console.print(" Database reset cancelled.")
599
+ return
600
+
601
+ try:
602
+ if backup:
603
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
604
+ backup_path = db_path.with_name(f"raze.db.backup-{timestamp}")
605
+ db_path.replace(backup_path)
606
+ console.print(f" Database moved to [dim]{backup_path}[/dim]")
607
+ else:
608
+ db_path.unlink()
609
+ console.print(f" Deleted [dim]{db_path}[/dim]")
610
+ except PermissionError:
611
+ console.print(" [red]Could not reset the database because it is in use. Stop RAZE Core first.[/red]")
612
+ raise typer.Exit(1)
613
+
614
+ console.print(" Local database reset complete. Restart RAZE Core to recreate the schema.")
615
+
616
+
617
+ if __name__ == "__main__":
618
+ app()
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: raze-cli
3
+ Version: 1.0.0
4
+ Summary: RAZE CLI - Local-first AI workforce command line interface
5
+ Author-email: Mohammed Alanazi <mtma.1@hotmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mtma1/Raze-1
8
+ Project-URL: Repository, https://github.com/mtma1/Raze-1
9
+ Project-URL: Issues, https://github.com/mtma1/Raze-1/issues
10
+ Keywords: ai,cli,agents,automation,local-first,raze
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: typer>=0.15.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: httpx>=0.28.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: grpc
25
+ Requires-Dist: grpcio>=1.78.0; extra == "grpc"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
28
+
29
+ # RAZE CLI
30
+
31
+ RAZE CLI is the local command line entrypoint for the RAZE runtime.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install raze-cli
37
+ ```
38
+
39
+ Optional gRPC transport support:
40
+
41
+ ```bash
42
+ pip install "raze-cli[grpc]"
43
+ ```
44
+
45
+ ## First run
46
+
47
+ ```bash
48
+ raze setup
49
+ raze doctor
50
+ raze dashboard
51
+ ```
52
+
53
+ ## What the CLI expects
54
+
55
+ - A writable local runtime directory via `RAZE_DATA_DIR` or the default user data path
56
+ - A reachable `raze_core` runtime when you want to use Core-backed commands
57
+ - A separate dashboard build or the dashboard source tree when you want to launch the UI
58
+ - When `grpcio` is installed and Core exposes the localhost control plane, the CLI will prefer gRPC automatically and fall back to REST when needed
59
+
60
+ ## Common commands
61
+
62
+ ```bash
63
+ raze config
64
+ raze list
65
+ raze install research-employee
66
+ raze run research-employee
67
+ raze reset-db --yes
68
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ raze_cli/__init__.py
4
+ raze_cli/api_client.py
5
+ raze_cli/grpc_client.py
6
+ raze_cli/main.py
7
+ raze_cli.egg-info/PKG-INFO
8
+ raze_cli.egg-info/SOURCES.txt
9
+ raze_cli.egg-info/dependency_links.txt
10
+ raze_cli.egg-info/entry_points.txt
11
+ raze_cli.egg-info/requires.txt
12
+ raze_cli.egg-info/top_level.txt
13
+ tests/test_cli_runtime_helpers.py
14
+ tests/test_grpc_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ raze = raze_cli.main:app
@@ -0,0 +1,10 @@
1
+ typer>=0.15.0
2
+ rich>=13.0.0
3
+ httpx>=0.28.0
4
+ pyyaml>=6.0
5
+
6
+ [dev]
7
+ pytest>=8.0.0
8
+
9
+ [grpc]
10
+ grpcio>=1.78.0
@@ -0,0 +1 @@
1
+ raze_cli
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ sys.path.insert(0, str(ROOT / "cli"))
9
+
10
+ from raze_cli import main # noqa: E402
11
+
12
+
13
+ def test_get_source_tree_root_detects_expected_layout(monkeypatch, tmp_path: Path):
14
+ workspace = tmp_path / "raze"
15
+ (workspace / "services" / "core").mkdir(parents=True)
16
+ (workspace / "apps" / "dashboard").mkdir(parents=True)
17
+
18
+ monkeypatch.setattr(main, "get_workspace_root", lambda: workspace)
19
+
20
+ assert main.get_source_tree_root() == workspace
21
+
22
+
23
+ def test_render_dashboard_command_returns_none_without_source_tree(monkeypatch):
24
+ monkeypatch.setattr(main, "get_source_tree_root", lambda: None)
25
+
26
+ assert main._render_dashboard_command(52000) is None
27
+
28
+
29
+ def test_render_dashboard_command_uses_dashboard_source_dir(monkeypatch, tmp_path: Path):
30
+ workspace = tmp_path / "raze"
31
+ dashboard_dir = workspace / "apps" / "dashboard"
32
+ dashboard_dir.mkdir(parents=True)
33
+ (workspace / "services" / "core").mkdir(parents=True)
34
+
35
+ monkeypatch.setattr(main, "get_source_tree_root", lambda: workspace)
36
+
37
+ command = main._render_dashboard_command(52000)
38
+ assert command == f"cd {dashboard_dir} && npm run dev -- --port 52000"
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+
8
+ import pytest
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[2]
12
+ sys.path.insert(0, str(ROOT / "cli"))
13
+
14
+ from raze_cli.api_client import RazeAPIClient # noqa: E402
15
+ from raze_cli.grpc_client import RazeGrpcClient # noqa: E402
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_grpc_client_reads_runtime_target(monkeypatch, tmp_path: Path):
20
+ calls: list[tuple[str, dict]] = []
21
+
22
+ class FakeMethod:
23
+ def __init__(self, path: str):
24
+ self.path = path
25
+
26
+ async def __call__(self, payload: dict):
27
+ calls.append((self.path, payload))
28
+ return {"path": self.path}
29
+
30
+ class FakeChannel:
31
+ def __init__(self, target: str):
32
+ self.target = target
33
+
34
+ async def __aenter__(self):
35
+ return self
36
+
37
+ async def __aexit__(self, exc_type, exc, tb):
38
+ return False
39
+
40
+ def unary_unary(self, path: str, request_serializer=None, response_deserializer=None):
41
+ return FakeMethod(path)
42
+
43
+ fake_grpc = SimpleNamespace(aio=SimpleNamespace(insecure_channel=lambda target: FakeChannel(target)))
44
+
45
+ monkeypatch.setenv("RAZE_DATA_DIR", str(tmp_path))
46
+ runtime_path = tmp_path / "runtime.json"
47
+ runtime_path.write_text(
48
+ json.dumps(
49
+ {
50
+ "grpc_host": "127.0.0.1",
51
+ "grpc_port": 52110,
52
+ "grpc_status": "running",
53
+ }
54
+ ),
55
+ encoding="utf-8",
56
+ )
57
+
58
+ client = RazeGrpcClient()
59
+ client._grpc = fake_grpc
60
+
61
+ result = await client.get_settings()
62
+
63
+ assert client.available() is True
64
+ assert result["path"] == "/raze.v1.SystemService/GetSettings"
65
+ assert calls == [("/raze.v1.SystemService/GetSettings", {})]
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_api_client_prefers_grpc_when_available():
70
+ class FakeGrpcClient:
71
+ def available(self):
72
+ return True
73
+
74
+ async def get_settings(self):
75
+ return {"transport": "grpc"}
76
+
77
+ client = RazeAPIClient()
78
+ client.grpc = FakeGrpcClient()
79
+
80
+ result = await client.get_settings()
81
+
82
+ assert result == {"transport": "grpc"}