r2flow-cloud-cli 0.1.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.
- r2flow_cloud_cli-0.1.0/.gitignore +20 -0
- r2flow_cloud_cli-0.1.0/PKG-INFO +8 -0
- r2flow_cloud_cli-0.1.0/README.md +55 -0
- r2flow_cloud_cli-0.1.0/pyproject.toml +20 -0
- r2flow_cloud_cli-0.1.0/src/r2flow_cloud_cli/__init__.py +5 -0
- r2flow_cloud_cli-0.1.0/src/r2flow_cloud_cli/client.py +223 -0
- r2flow_cloud_cli-0.1.0/src/r2flow_cloud_cli/main.py +315 -0
- r2flow_cloud_cli-0.1.0/src/r2flow_cloud_cli/packager.py +71 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.coverage
|
|
9
|
+
*.egg-info/
|
|
10
|
+
dist/
|
|
11
|
+
node_modules/
|
|
12
|
+
designer-web/dist/
|
|
13
|
+
frontend/dist/
|
|
14
|
+
.env
|
|
15
|
+
flow.json
|
|
16
|
+
flows/
|
|
17
|
+
*.log
|
|
18
|
+
.vscode/
|
|
19
|
+
.idea/
|
|
20
|
+
.DS_Store
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# r2flow-cloud-cli
|
|
2
|
+
|
|
3
|
+
CLI for deploying and managing processes on the R2Flow Cloud orchestrator.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd cli
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
Set the orchestrator URL via the `--orchestrator` flag or the `R2FLOW_CLOUD_URL` environment variable (default: `http://localhost:8000`).
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### Deploy a project
|
|
19
|
+
|
|
20
|
+
Pack a local directory and upload it as a new process:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
r2flow-cloud deploy ./my-process --name "Data Pipeline" --description "Nightly ETL" --entry main.py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### List processes
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
r2flow-cloud processes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### List agents
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
r2flow-cloud agents
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Run a process on an agent
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
r2flow-cloud run <process_id> <agent_id>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Custom orchestrator URL
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
r2flow-cloud --orchestrator http://remote-host:8000 deploy ./my-process --name "Pipeline"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or via environment variable:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export R2FLOW_CLOUD_URL=http://remote-host:8000
|
|
54
|
+
r2flow-cloud deploy ./my-process --name "Pipeline"
|
|
55
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "r2flow-cloud-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for deploying and managing R2Flow Cloud processes"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx",
|
|
12
|
+
"rich",
|
|
13
|
+
"click",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
r2flow-cloud = "r2flow_cloud_cli.main:cli"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/r2flow_cloud_cli"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Async HTTP client for the R2Flow orchestrator API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _credentials_path() -> Path:
|
|
14
|
+
"""Location of the CLI token store (``~/.r2flow/credentials.json``)."""
|
|
15
|
+
return Path.home() / ".r2flow" / "credentials.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_tokens(base_url: str) -> dict[str, str]:
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(_credentials_path().read_text(encoding="utf-8"))
|
|
21
|
+
except (OSError, ValueError):
|
|
22
|
+
return {}
|
|
23
|
+
entry = data.get(base_url) if isinstance(data, dict) else None
|
|
24
|
+
return entry if isinstance(entry, dict) else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save_tokens(base_url: str, tokens: dict[str, str] | None) -> None:
|
|
28
|
+
import os
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
|
|
31
|
+
path = _credentials_path()
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
data = {}
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
data = {}
|
|
38
|
+
if tokens is None:
|
|
39
|
+
data.pop(base_url, None)
|
|
40
|
+
else:
|
|
41
|
+
data[base_url] = tokens
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
44
|
+
with suppress(OSError):
|
|
45
|
+
os.chmod(path, 0o600)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OrchestratorClient:
|
|
49
|
+
"""Thin wrapper around :mod:`httpx` for the orchestrator REST API.
|
|
50
|
+
|
|
51
|
+
Bearer tokens saved by ``r2flow-cloud login`` are loaded automatically
|
|
52
|
+
and refreshed transparently when the server answers 401.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, base_url: str = "http://localhost:8000") -> None:
|
|
56
|
+
self._base = base_url.rstrip("/")
|
|
57
|
+
self._http = httpx.AsyncClient(base_url=self._base, timeout=30)
|
|
58
|
+
tokens = _load_tokens(self._base)
|
|
59
|
+
self._access_token: str | None = tokens.get("access_token")
|
|
60
|
+
self._refresh_token: str | None = tokens.get("refresh_token")
|
|
61
|
+
|
|
62
|
+
# -- context-manager support ------------------------------------------------
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> OrchestratorClient:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(
|
|
68
|
+
self,
|
|
69
|
+
exc_type: type[BaseException] | None,
|
|
70
|
+
exc_val: BaseException | None,
|
|
71
|
+
exc_tb: object,
|
|
72
|
+
) -> None:
|
|
73
|
+
await self._http.aclose()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def logged_in(self) -> bool:
|
|
77
|
+
"""Whether stored user credentials are available."""
|
|
78
|
+
return self._access_token is not None
|
|
79
|
+
|
|
80
|
+
# -- auth -----------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async def login(self, email: str, password: str) -> dict[str, Any]:
|
|
83
|
+
"""Authenticate via ``POST /api/auth/login`` and persist the token pair."""
|
|
84
|
+
resp = await self._http.post(
|
|
85
|
+
"/api/auth/login", data={"username": email, "password": password}
|
|
86
|
+
)
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
body = cast(dict[str, Any], resp.json())
|
|
89
|
+
self._store_tokens(str(body["access_token"]), str(body["refresh_token"]))
|
|
90
|
+
return await self.me()
|
|
91
|
+
|
|
92
|
+
async def logout(self) -> None:
|
|
93
|
+
"""Revoke the refresh token server-side and drop local credentials."""
|
|
94
|
+
if self._refresh_token:
|
|
95
|
+
with suppress(httpx.HTTPError):
|
|
96
|
+
await self._http.post(
|
|
97
|
+
"/api/auth/logout", json={"refresh_token": self._refresh_token}
|
|
98
|
+
)
|
|
99
|
+
self._access_token = None
|
|
100
|
+
self._refresh_token = None
|
|
101
|
+
_save_tokens(self._base, None)
|
|
102
|
+
|
|
103
|
+
async def me(self) -> dict[str, Any]:
|
|
104
|
+
"""Return the current user via ``GET /api/auth/me``."""
|
|
105
|
+
resp = await self._send("GET", "/api/auth/me")
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
return cast(dict[str, Any], resp.json())
|
|
108
|
+
|
|
109
|
+
# -- transport --------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _store_tokens(self, access_token: str, refresh_token: str) -> None:
|
|
112
|
+
self._access_token = access_token
|
|
113
|
+
self._refresh_token = refresh_token
|
|
114
|
+
_save_tokens(
|
|
115
|
+
self._base,
|
|
116
|
+
{"access_token": access_token, "refresh_token": refresh_token},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def _refresh_access(self) -> bool:
|
|
120
|
+
"""Rotate the token pair. Returns True when new tokens were stored."""
|
|
121
|
+
if not self._refresh_token:
|
|
122
|
+
return False
|
|
123
|
+
try:
|
|
124
|
+
resp = await self._http.post(
|
|
125
|
+
"/api/auth/refresh", json={"refresh_token": self._refresh_token}
|
|
126
|
+
)
|
|
127
|
+
except httpx.HTTPError:
|
|
128
|
+
return False
|
|
129
|
+
if resp.status_code != 200:
|
|
130
|
+
self._access_token = None
|
|
131
|
+
self._refresh_token = None
|
|
132
|
+
_save_tokens(self._base, None)
|
|
133
|
+
return False
|
|
134
|
+
body = cast(dict[str, Any], resp.json())
|
|
135
|
+
self._store_tokens(str(body["access_token"]), str(body["refresh_token"]))
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
async def _send(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
139
|
+
"""Send a request with Bearer auth, refreshing once on 401."""
|
|
140
|
+
if self._access_token:
|
|
141
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
142
|
+
headers.setdefault("Authorization", f"Bearer {self._access_token}")
|
|
143
|
+
kwargs["headers"] = headers
|
|
144
|
+
resp = await self._http.request(method, url, **kwargs)
|
|
145
|
+
if (
|
|
146
|
+
resp.status_code == 401
|
|
147
|
+
and self._refresh_token
|
|
148
|
+
and await self._refresh_access()
|
|
149
|
+
and self._access_token
|
|
150
|
+
):
|
|
151
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
152
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
153
|
+
kwargs["headers"] = headers
|
|
154
|
+
resp = await self._http.request(method, url, **kwargs)
|
|
155
|
+
return resp
|
|
156
|
+
|
|
157
|
+
# -- processes --------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def create_process(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
name: str,
|
|
163
|
+
description: str = "",
|
|
164
|
+
entry_point: str = "main.py",
|
|
165
|
+
files: dict[str, str],
|
|
166
|
+
requirements: list[str] | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Create a new process via ``POST /api/processes``."""
|
|
169
|
+
resp = await self._send(
|
|
170
|
+
"POST",
|
|
171
|
+
"/api/processes",
|
|
172
|
+
json={
|
|
173
|
+
"name": name,
|
|
174
|
+
"description": description,
|
|
175
|
+
"entry_point": entry_point,
|
|
176
|
+
"files": files,
|
|
177
|
+
"requirements": requirements or [],
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
return cast(dict[str, Any], resp.json())
|
|
182
|
+
|
|
183
|
+
async def update_process(self, process_id: str, **kwargs: object) -> dict[str, Any]:
|
|
184
|
+
"""Update an existing process via ``PUT /api/processes/{id}``."""
|
|
185
|
+
resp = await self._send("PUT", f"/api/processes/{process_id}", json=kwargs)
|
|
186
|
+
resp.raise_for_status()
|
|
187
|
+
return cast(dict[str, Any], resp.json())
|
|
188
|
+
|
|
189
|
+
async def list_processes(self) -> list[dict[str, Any]]:
|
|
190
|
+
"""Return all processes via ``GET /api/processes``."""
|
|
191
|
+
resp = await self._send("GET", "/api/processes")
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
return cast(list[dict[str, Any]], resp.json())
|
|
194
|
+
|
|
195
|
+
# -- agents ----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async def list_agents(self) -> list[dict[str, Any]]:
|
|
198
|
+
"""Return all registered agents via ``GET /api/agents``."""
|
|
199
|
+
resp = await self._send("GET", "/api/agents")
|
|
200
|
+
resp.raise_for_status()
|
|
201
|
+
return cast(list[dict[str, Any]], resp.json())
|
|
202
|
+
|
|
203
|
+
# -- deployment / execution ------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async def deploy(self, process_id: str, agent_id: str) -> dict[str, Any]:
|
|
206
|
+
"""Deploy a process to an agent via ``POST /api/processes/{id}/deploy``."""
|
|
207
|
+
resp = await self._send(
|
|
208
|
+
"POST",
|
|
209
|
+
f"/api/processes/{process_id}/deploy",
|
|
210
|
+
json={"agent_id": agent_id},
|
|
211
|
+
)
|
|
212
|
+
resp.raise_for_status()
|
|
213
|
+
return cast(dict[str, Any], resp.json())
|
|
214
|
+
|
|
215
|
+
async def run(self, process_id: str, agent_id: str) -> dict[str, Any]:
|
|
216
|
+
"""Run a process on an agent via ``POST /api/processes/{id}/run``."""
|
|
217
|
+
resp = await self._send(
|
|
218
|
+
"POST",
|
|
219
|
+
f"/api/processes/{process_id}/run",
|
|
220
|
+
json={"agent_id": agent_id},
|
|
221
|
+
)
|
|
222
|
+
resp.raise_for_status()
|
|
223
|
+
return cast(dict[str, Any], resp.json())
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""CLI entry-point — ``r2flow-cloud`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from r2flow_cloud_cli.client import OrchestratorClient
|
|
13
|
+
from r2flow_cloud_cli.packager import read_directory, read_requirements
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Helper: resolve orchestrator URL from option / env-var / default
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _client_ctx(ctx: click.Context) -> OrchestratorClient:
|
|
24
|
+
return OrchestratorClient(base_url=ctx.obj["orchestrator"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Root group
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.option(
|
|
34
|
+
"--orchestrator",
|
|
35
|
+
envvar="R2FLOW_CLOUD_URL",
|
|
36
|
+
default="http://localhost:8000",
|
|
37
|
+
show_default=True,
|
|
38
|
+
help="URL of the orchestrator service.",
|
|
39
|
+
)
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def cli(ctx: click.Context, orchestrator: str) -> None:
|
|
42
|
+
"""R2Flow Cloud CLI — deploy and run processes on remote agents."""
|
|
43
|
+
ctx.ensure_object(dict)
|
|
44
|
+
ctx.obj["orchestrator"] = orchestrator
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# deploy
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False))
|
|
54
|
+
@click.option("--name", required=True, help="Process name.")
|
|
55
|
+
@click.option("--description", default="", help="Human-readable description.")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--entry",
|
|
58
|
+
"entry_point",
|
|
59
|
+
default="main.py",
|
|
60
|
+
show_default=True,
|
|
61
|
+
help="Entry-point filename.",
|
|
62
|
+
)
|
|
63
|
+
@click.pass_context
|
|
64
|
+
def deploy(ctx: click.Context, path: str, name: str, description: str, entry_point: str) -> None:
|
|
65
|
+
"""Pack a directory and upload it as a new process."""
|
|
66
|
+
console.print(f"[bold]Packing[/bold] {path} …")
|
|
67
|
+
files = read_directory(path)
|
|
68
|
+
if not files:
|
|
69
|
+
console.print("[red]No files found in the given directory.[/red]")
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
requirements = read_requirements(path)
|
|
73
|
+
console.print(f" {len(files)} file(s), {len(requirements)} requirement(s)")
|
|
74
|
+
|
|
75
|
+
async def _create() -> dict[str, Any]:
|
|
76
|
+
async with _client_ctx(ctx) as client:
|
|
77
|
+
return await client.create_process(
|
|
78
|
+
name=name,
|
|
79
|
+
description=description,
|
|
80
|
+
entry_point=entry_point,
|
|
81
|
+
files=files,
|
|
82
|
+
requirements=requirements,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
result = asyncio.run(_create())
|
|
86
|
+
pid = result.get("id") or result.get("process_id") or "?"
|
|
87
|
+
console.print(f"[green]✓ Process created[/green] id={pid}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# update
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command()
|
|
96
|
+
@click.argument("process_id")
|
|
97
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False))
|
|
98
|
+
@click.option(
|
|
99
|
+
"--entry",
|
|
100
|
+
"entry_point",
|
|
101
|
+
default="main.py",
|
|
102
|
+
show_default=True,
|
|
103
|
+
help="Entry-point filename.",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--agent",
|
|
107
|
+
"agent_ref",
|
|
108
|
+
default=None,
|
|
109
|
+
help="Agent name or ID to deploy to (implies --deploy).",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--deploy/--no-deploy",
|
|
113
|
+
default=False,
|
|
114
|
+
show_default=True,
|
|
115
|
+
help="Deploy the updated bundle to the agent given via --agent.",
|
|
116
|
+
)
|
|
117
|
+
@click.pass_context
|
|
118
|
+
def update(
|
|
119
|
+
ctx: click.Context,
|
|
120
|
+
process_id: str,
|
|
121
|
+
path: str,
|
|
122
|
+
entry_point: str,
|
|
123
|
+
agent_ref: str | None,
|
|
124
|
+
deploy: bool,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Re-pack a directory and update an existing process in place."""
|
|
127
|
+
if deploy and not agent_ref:
|
|
128
|
+
console.print("[red]--deploy requires --agent <name|id>.[/red]")
|
|
129
|
+
raise SystemExit(2)
|
|
130
|
+
|
|
131
|
+
console.print(f"[bold]Packing[/bold] {path} …")
|
|
132
|
+
files = read_directory(path)
|
|
133
|
+
if not files:
|
|
134
|
+
console.print("[red]No files found in the given directory.[/red]")
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
|
|
137
|
+
requirements = read_requirements(path)
|
|
138
|
+
console.print(f" {len(files)} file(s), {len(requirements)} requirement(s)")
|
|
139
|
+
|
|
140
|
+
async def _update() -> dict[str, Any]:
|
|
141
|
+
async with _client_ctx(ctx) as client:
|
|
142
|
+
return await client.update_process(
|
|
143
|
+
process_id,
|
|
144
|
+
files=files,
|
|
145
|
+
requirements=requirements,
|
|
146
|
+
entry_point=entry_point,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
asyncio.run(_update())
|
|
151
|
+
except Exception as err:
|
|
152
|
+
console.print(f"[red]Update failed:[/red] {err}")
|
|
153
|
+
raise SystemExit(1) from err
|
|
154
|
+
console.print(f"[green]✓ Process updated[/green] id={process_id}")
|
|
155
|
+
|
|
156
|
+
if not agent_ref:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
async def _deploy() -> tuple[str, dict[str, Any]]:
|
|
160
|
+
async with _client_ctx(ctx) as client:
|
|
161
|
+
agents = await client.list_agents()
|
|
162
|
+
match = next(
|
|
163
|
+
(
|
|
164
|
+
a
|
|
165
|
+
for a in agents
|
|
166
|
+
if a.get("id") == agent_ref or a.get("name") == agent_ref
|
|
167
|
+
),
|
|
168
|
+
None,
|
|
169
|
+
)
|
|
170
|
+
if match is None:
|
|
171
|
+
raise LookupError(f"no agent matching {agent_ref!r}")
|
|
172
|
+
agent_id = str(match["id"])
|
|
173
|
+
return agent_id, await client.deploy(process_id, agent_id)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
agent_id, _ = asyncio.run(_deploy())
|
|
177
|
+
except LookupError as err:
|
|
178
|
+
console.print(f"[red]Deploy failed:[/red] {err}")
|
|
179
|
+
raise SystemExit(1) from err
|
|
180
|
+
except Exception as err:
|
|
181
|
+
console.print(f"[red]Deploy failed:[/red] {err}")
|
|
182
|
+
raise SystemExit(1) from err
|
|
183
|
+
console.print(f"[green]✓ Deploy triggered[/green] agent={agent_id}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# processes
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@cli.command()
|
|
192
|
+
@click.pass_context
|
|
193
|
+
def processes(ctx: click.Context) -> None:
|
|
194
|
+
"""List all processes."""
|
|
195
|
+
|
|
196
|
+
async def _list() -> list[dict[str, Any]]:
|
|
197
|
+
async with _client_ctx(ctx) as client:
|
|
198
|
+
return await client.list_processes()
|
|
199
|
+
|
|
200
|
+
items = asyncio.run(_list())
|
|
201
|
+
|
|
202
|
+
table = Table(title="Processes")
|
|
203
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
204
|
+
table.add_column("Name", style="green")
|
|
205
|
+
table.add_column("Entry Point")
|
|
206
|
+
table.add_column("Description")
|
|
207
|
+
|
|
208
|
+
for p in items:
|
|
209
|
+
table.add_row(
|
|
210
|
+
str(p.get("id", "")),
|
|
211
|
+
p.get("name", ""),
|
|
212
|
+
p.get("entry_point", ""),
|
|
213
|
+
p.get("description", ""),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
console.print(table)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# agents
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@cli.command()
|
|
225
|
+
@click.pass_context
|
|
226
|
+
def agents(ctx: click.Context) -> None:
|
|
227
|
+
"""List all registered agents."""
|
|
228
|
+
|
|
229
|
+
async def _list() -> list[dict[str, Any]]:
|
|
230
|
+
async with _client_ctx(ctx) as client:
|
|
231
|
+
return await client.list_agents()
|
|
232
|
+
|
|
233
|
+
items = asyncio.run(_list())
|
|
234
|
+
|
|
235
|
+
table = Table(title="Agents")
|
|
236
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
237
|
+
table.add_column("Name", style="green")
|
|
238
|
+
table.add_column("Status")
|
|
239
|
+
|
|
240
|
+
for a in items:
|
|
241
|
+
table.add_row(
|
|
242
|
+
str(a.get("id", "")),
|
|
243
|
+
a.get("name", ""),
|
|
244
|
+
a.get("status", ""),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
console.print(table)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# login / logout
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@cli.command()
|
|
256
|
+
@click.option("--email", prompt=True, help="Account email.")
|
|
257
|
+
@click.option(
|
|
258
|
+
"--password",
|
|
259
|
+
prompt=True,
|
|
260
|
+
hide_input=True,
|
|
261
|
+
help="Account password.",
|
|
262
|
+
)
|
|
263
|
+
@click.pass_context
|
|
264
|
+
def login(ctx: click.Context, email: str, password: str) -> None:
|
|
265
|
+
"""Authenticate against the orchestrator and store tokens locally."""
|
|
266
|
+
|
|
267
|
+
async def _login() -> dict[str, Any]:
|
|
268
|
+
async with _client_ctx(ctx) as client:
|
|
269
|
+
return await client.login(email, password)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
user = asyncio.run(_login())
|
|
273
|
+
except Exception as err:
|
|
274
|
+
console.print(f"[red]Login failed:[/red] {err}")
|
|
275
|
+
raise SystemExit(1) from err
|
|
276
|
+
console.print(
|
|
277
|
+
f"[green]✓ Logged in[/green] {user.get('email')} (role={user.get('role')})"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@cli.command()
|
|
282
|
+
@click.pass_context
|
|
283
|
+
def logout(ctx: click.Context) -> None:
|
|
284
|
+
"""Revoke the stored session and drop local credentials."""
|
|
285
|
+
|
|
286
|
+
async def _logout() -> None:
|
|
287
|
+
async with _client_ctx(ctx) as client:
|
|
288
|
+
await client.logout()
|
|
289
|
+
|
|
290
|
+
asyncio.run(_logout())
|
|
291
|
+
console.print("[green]✓ Logged out[/green]")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# run
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@cli.command()
|
|
300
|
+
@click.argument("process_id")
|
|
301
|
+
@click.argument("agent_id")
|
|
302
|
+
@click.pass_context
|
|
303
|
+
def run(ctx: click.Context, process_id: str, agent_id: str) -> None:
|
|
304
|
+
"""Trigger a process run on an agent."""
|
|
305
|
+
|
|
306
|
+
async def _run() -> dict[str, Any]:
|
|
307
|
+
async with _client_ctx(ctx) as client:
|
|
308
|
+
return await client.run(process_id, agent_id)
|
|
309
|
+
|
|
310
|
+
result = asyncio.run(_run())
|
|
311
|
+
console.print(f"[green]✓ Run triggered[/green] {result}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
cli()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Read a project directory into a JSON-serialisable files dict and requirements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_EXCLUDE_DIRS: set[str] = {
|
|
9
|
+
".git",
|
|
10
|
+
".venv",
|
|
11
|
+
"__pycache__",
|
|
12
|
+
"node_modules",
|
|
13
|
+
".ruff_cache",
|
|
14
|
+
".mypy_cache",
|
|
15
|
+
".pytest_cache",
|
|
16
|
+
".gigacode",
|
|
17
|
+
}
|
|
18
|
+
_EXCLUDE_GLOBS: set[str] = {"*.pyc", "*.pyo", ".env"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_directory(path: str | Path) -> dict[str, str]:
|
|
22
|
+
"""Walk *path* recursively and return ``{relative_path: content}`` dicts.
|
|
23
|
+
|
|
24
|
+
Skips directories and files in ``_EXCLUDE_DIRS`` / ``_EXCLUDE_GLOBS``.
|
|
25
|
+
Binary files (those that raise :class:`UnicodeDecodeError`) are silently
|
|
26
|
+
skipped.
|
|
27
|
+
"""
|
|
28
|
+
root = Path(path).resolve()
|
|
29
|
+
files: dict[str, str] = {}
|
|
30
|
+
|
|
31
|
+
for entry in sorted(root.rglob("*")):
|
|
32
|
+
if not entry.is_file():
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Directory name check (walks through each parent too).
|
|
36
|
+
if any(part in _EXCLUDE_DIRS for part in entry.relative_to(root).parts):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
name = entry.name
|
|
40
|
+
if any(fnmatch.fnmatch(name, pat) for pat in _EXCLUDE_GLOBS):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
rel = entry.relative_to(root).as_posix()
|
|
44
|
+
try:
|
|
45
|
+
files[rel] = entry.read_text(encoding="utf-8")
|
|
46
|
+
except UnicodeDecodeError:
|
|
47
|
+
continue # binary file — skip
|
|
48
|
+
|
|
49
|
+
return files
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_requirements(path: str | Path | None = None) -> list[str]:
|
|
53
|
+
"""Return requirements lines from *requirements.txt* if it exists.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
path:
|
|
58
|
+
Directory that may contain ``requirements.txt``. Defaults to cwd.
|
|
59
|
+
"""
|
|
60
|
+
req_file = Path(path) if path else Path.cwd()
|
|
61
|
+
req_file = req_file / "requirements.txt"
|
|
62
|
+
|
|
63
|
+
if not req_file.is_file():
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
lines = [
|
|
67
|
+
line.strip()
|
|
68
|
+
for line in req_file.read_text(encoding="utf-8").splitlines()
|
|
69
|
+
if line.strip() and not line.strip().startswith("#")
|
|
70
|
+
]
|
|
71
|
+
return lines
|