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.
- raze_cli-1.0.0/PKG-INFO +68 -0
- raze_cli-1.0.0/README.md +40 -0
- raze_cli-1.0.0/pyproject.toml +53 -0
- raze_cli-1.0.0/raze_cli/__init__.py +3 -0
- raze_cli-1.0.0/raze_cli/api_client.py +233 -0
- raze_cli-1.0.0/raze_cli/grpc_client.py +104 -0
- raze_cli-1.0.0/raze_cli/main.py +618 -0
- raze_cli-1.0.0/raze_cli.egg-info/PKG-INFO +68 -0
- raze_cli-1.0.0/raze_cli.egg-info/SOURCES.txt +14 -0
- raze_cli-1.0.0/raze_cli.egg-info/dependency_links.txt +1 -0
- raze_cli-1.0.0/raze_cli.egg-info/entry_points.txt +2 -0
- raze_cli-1.0.0/raze_cli.egg-info/requires.txt +10 -0
- raze_cli-1.0.0/raze_cli.egg-info/top_level.txt +1 -0
- raze_cli-1.0.0/setup.cfg +4 -0
- raze_cli-1.0.0/tests/test_cli_runtime_helpers.py +38 -0
- raze_cli-1.0.0/tests/test_grpc_client.py +82 -0
raze_cli-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
raze_cli-1.0.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
raze_cli
|
raze_cli-1.0.0/setup.cfg
ADDED
|
@@ -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"}
|