fluxhive-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxhive_cli-0.1.0.dist-info/METADATA +94 -0
- fluxhive_cli-0.1.0.dist-info/RECORD +18 -0
- fluxhive_cli-0.1.0.dist-info/WHEEL +5 -0
- fluxhive_cli-0.1.0.dist-info/entry_points.txt +3 -0
- fluxhive_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
- fluxhive_cli-0.1.0.dist-info/top_level.txt +1 -0
- fluxhivectl/__init__.py +5 -0
- fluxhivectl/client.py +175 -0
- fluxhivectl/commands/__init__.py +16 -0
- fluxhivectl/commands/agents.py +191 -0
- fluxhivectl/commands/auth.py +134 -0
- fluxhivectl/commands/config.py +65 -0
- fluxhivectl/commands/jobs.py +235 -0
- fluxhivectl/commands/runs.py +122 -0
- fluxhivectl/config.py +158 -0
- fluxhivectl/context.py +16 -0
- fluxhivectl/formatting.py +83 -0
- fluxhivectl/main.py +52 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fluxhive-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FluxHive control-plane command line client
|
|
5
|
+
Author: FluxHive Team
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# @fluxhive/cli
|
|
12
|
+
|
|
13
|
+
Node-installable launcher and Python implementation for the FluxHive control-plane CLI.
|
|
14
|
+
|
|
15
|
+
The CLI talks to Control Server HTTP APIs and does not implement scheduling or agent runtime logic locally.
|
|
16
|
+
|
|
17
|
+
The package exposes two equivalent commands:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
fluxhive
|
|
21
|
+
fluxhivectl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## npm Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g @fluxhive/cli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## PyPI Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pipx install fluxhive-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The compatibility package `fluxhivectl` also installs the same CLI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pipx install fluxhivectl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with pip:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install fluxhive-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python 3.10 or newer must be available on `PATH`. You can also point the launcher at a specific Python executable:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
FLUXHIVE_PYTHON=/path/to/python fluxhive --version
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
On Windows PowerShell:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
$env:FLUXHIVE_PYTHON = "C:\Python312\python.exe"
|
|
58
|
+
fluxhive --version
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Development Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m pip install -e .
|
|
65
|
+
npm test
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
fluxhive config base_url http://127.0.0.1:8001
|
|
72
|
+
fluxhive auth login --username <username>
|
|
73
|
+
|
|
74
|
+
fluxhive jobs list
|
|
75
|
+
fluxhive agents list
|
|
76
|
+
fluxhive runs list --limit 20
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
You can also use environment variables:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
FLUXHIVE_CONTROL_URL=http://127.0.0.1:8001
|
|
83
|
+
FLUXHIVE_ACCESS_TOKEN=<access-token>
|
|
84
|
+
FLUXHIVE_REFRESH_TOKEN=<refresh-token>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Command Groups
|
|
88
|
+
|
|
89
|
+
- `auth`: login, refresh tokens, inspect current user, clear local tokens
|
|
90
|
+
- `config`: read and write local CLI configuration
|
|
91
|
+
- `jobs`: create, list, inspect, publish, run, cancel, clone, and delete jobs
|
|
92
|
+
- `runs`: list run attempts, inspect run attempts, print stored run logs
|
|
93
|
+
- `agents`: list agents, inspect GPUs, inspect queues, rename agents
|
|
94
|
+
- `nodes`: alias for `agents`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
fluxhive_cli-0.1.0.dist-info/licenses/LICENSE,sha256=G4fUe0QUMUfZ0ZBWfVO3H9Kae2mmIBce8ehGZshxvQU,841
|
|
2
|
+
fluxhivectl/__init__.py,sha256=ZVs5hyMqqWVCrQAWR86Ns5ubYhf14Tt34sXV8BHV7DI,106
|
|
3
|
+
fluxhivectl/client.py,sha256=1WQmaPJnzyNjnpaYHutC0BYaNWQsZZP4y1z9BcPICGs,6758
|
|
4
|
+
fluxhivectl/config.py,sha256=22QMAk8j8m5DIyMqdHDtMK3eW-62Z4KtdkfeGptHlVE,4795
|
|
5
|
+
fluxhivectl/context.py,sha256=WS233XkgpHAhHgq6p6fBofilrS3BFHcZ2Fma-loqDAY,322
|
|
6
|
+
fluxhivectl/formatting.py,sha256=e88NQ83kRdfF8qCV3QgXl6u5JhS3tWHKP-qER6crrpI,2491
|
|
7
|
+
fluxhivectl/main.py,sha256=7PhcOG1o3-cqVdbrmRbxAU9yCacAHoAewrFpcuCZGo0,1497
|
|
8
|
+
fluxhivectl/commands/__init__.py,sha256=rC3uKIRvBpMsIWa6hRNT3MBaOeTV2rerlg84wm2AZQQ,439
|
|
9
|
+
fluxhivectl/commands/agents.py,sha256=yNwjeIBPWlFyGZSO63XjMr4NSVMRBe_zgt944W80cw0,6991
|
|
10
|
+
fluxhivectl/commands/auth.py,sha256=iGL7zU-WYD329J0VBKP1jEr4XsyLDma5pU4AGOpvTF0,4819
|
|
11
|
+
fluxhivectl/commands/config.py,sha256=5wSOzx0JB9LxKUAXpoQb-6lqMCaxZ9tQ-kClZwM-IX4,2233
|
|
12
|
+
fluxhivectl/commands/jobs.py,sha256=0fYRzcTMCBwzZwA0YqO8VA-lHSGL_-3BMuf9zQ_UtBg,9439
|
|
13
|
+
fluxhivectl/commands/runs.py,sha256=lQPIgAsJZlScENk8MGTB5T20ltCVKdqvedPEmrJDpvg,4332
|
|
14
|
+
fluxhive_cli-0.1.0.dist-info/METADATA,sha256=crEudevOsia2WMIdWagVmOMh94YdzmlS9MeQZq-DQo8,2031
|
|
15
|
+
fluxhive_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
fluxhive_cli-0.1.0.dist-info/entry_points.txt,sha256=E00ABn38gY7gAD5sE2oQTQrgrbPosZQXeZXJ7XGJCYI,87
|
|
17
|
+
fluxhive_cli-0.1.0.dist-info/top_level.txt,sha256=dcUVggBwcyGYE39YCTUuBS3uqwjBvxmLVifPbhL9Grs,12
|
|
18
|
+
fluxhive_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
FluxHive License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FluxHive
|
|
4
|
+
|
|
5
|
+
This repository contains multiple components with different licenses:
|
|
6
|
+
|
|
7
|
+
1. Agent Component (app-agent/)
|
|
8
|
+
Licensed under the FluxHive Agent Non-Commercial Copyleft License v1.0.
|
|
9
|
+
- Open source: You may view, modify, and distribute the source code
|
|
10
|
+
- Non-commercial: Commercial use is prohibited (separate commercial license required)
|
|
11
|
+
- Copyleft: Modifications and derivative works must remain open source
|
|
12
|
+
See app-agent/LICENSE for full details.
|
|
13
|
+
|
|
14
|
+
2. Control Server (app-control/)
|
|
15
|
+
Proprietary and confidential.
|
|
16
|
+
Unauthorized copying or distribution is strictly prohibited.
|
|
17
|
+
|
|
18
|
+
3. Web Client (app-web/)
|
|
19
|
+
Proprietary and confidential.
|
|
20
|
+
Unauthorized copying or distribution is strictly prohibited.
|
|
21
|
+
|
|
22
|
+
For commercial licensing or enterprise support, please contact:
|
|
23
|
+
[dramwig@gmail.com]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fluxhivectl
|
fluxhivectl/__init__.py
ADDED
fluxhivectl/client.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""HTTP client for FluxHive Control Server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import CLIConfig, save_values
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(RuntimeError):
|
|
17
|
+
def __init__(self, message: str, *, status: int | None = None, code: str | None = None):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
self.code = code
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
parts = []
|
|
24
|
+
if self.status is not None:
|
|
25
|
+
parts.append(str(self.status))
|
|
26
|
+
if self.code:
|
|
27
|
+
parts.append(self.code)
|
|
28
|
+
if parts:
|
|
29
|
+
return f"[{', '.join(parts)}] {super().__str__()}"
|
|
30
|
+
return super().__str__()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiClient:
|
|
34
|
+
def __init__(self, config: CLIConfig, *, timeout: float = 30.0):
|
|
35
|
+
self.config = config
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def get(self, path: str, *, query: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
39
|
+
return self.request("GET", path, query=query, auth=auth)
|
|
40
|
+
|
|
41
|
+
def post(
|
|
42
|
+
self,
|
|
43
|
+
path: str,
|
|
44
|
+
*,
|
|
45
|
+
body: dict[str, Any] | None = None,
|
|
46
|
+
query: dict[str, Any] | None = None,
|
|
47
|
+
auth: bool = True,
|
|
48
|
+
) -> Any:
|
|
49
|
+
return self.request("POST", path, body=body, query=query, auth=auth)
|
|
50
|
+
|
|
51
|
+
def put(self, path: str, *, body: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
52
|
+
return self.request("PUT", path, body=body, auth=auth)
|
|
53
|
+
|
|
54
|
+
def patch(self, path: str, *, body: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
55
|
+
return self.request("PATCH", path, body=body, auth=auth)
|
|
56
|
+
|
|
57
|
+
def delete(self, path: str, *, auth: bool = True) -> Any:
|
|
58
|
+
return self.request("DELETE", path, auth=auth)
|
|
59
|
+
|
|
60
|
+
def request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
body: dict[str, Any] | None = None,
|
|
66
|
+
query: dict[str, Any] | None = None,
|
|
67
|
+
auth: bool = True,
|
|
68
|
+
_retried_after_refresh: bool = False,
|
|
69
|
+
) -> Any:
|
|
70
|
+
url = self._build_url(path, query=query)
|
|
71
|
+
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
72
|
+
headers = {
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
"User-Agent": f"fluxhivectl/{__version__}",
|
|
75
|
+
}
|
|
76
|
+
if body is not None:
|
|
77
|
+
headers["Content-Type"] = "application/json"
|
|
78
|
+
if auth and self.config.access_token:
|
|
79
|
+
headers["Authorization"] = f"Bearer {self.config.access_token}"
|
|
80
|
+
|
|
81
|
+
request = urllib.request.Request(url, data=payload, headers=headers, method=method.upper())
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
84
|
+
response_body = response.read()
|
|
85
|
+
return self._parse_response(response.status, response_body)
|
|
86
|
+
except urllib.error.HTTPError as exc:
|
|
87
|
+
response_body = exc.read()
|
|
88
|
+
if (
|
|
89
|
+
exc.code == 401
|
|
90
|
+
and auth
|
|
91
|
+
and self.config.refresh_token
|
|
92
|
+
and not _retried_after_refresh
|
|
93
|
+
):
|
|
94
|
+
self._refresh_token()
|
|
95
|
+
return self.request(
|
|
96
|
+
method,
|
|
97
|
+
path,
|
|
98
|
+
body=body,
|
|
99
|
+
query=query,
|
|
100
|
+
auth=auth,
|
|
101
|
+
_retried_after_refresh=True,
|
|
102
|
+
)
|
|
103
|
+
self._raise_api_error(exc.code, response_body)
|
|
104
|
+
except urllib.error.URLError as exc:
|
|
105
|
+
raise ApiError(f"Failed to connect to Control Server: {exc.reason}") from exc
|
|
106
|
+
|
|
107
|
+
def _build_url(self, path: str, *, query: dict[str, Any] | None = None) -> str:
|
|
108
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
109
|
+
url = path
|
|
110
|
+
else:
|
|
111
|
+
url = f"{self.config.api_base_url}/{path.lstrip('/')}"
|
|
112
|
+
if query:
|
|
113
|
+
clean_query = {key: value for key, value in query.items() if value is not None}
|
|
114
|
+
if clean_query:
|
|
115
|
+
url = f"{url}?{urllib.parse.urlencode(clean_query, doseq=True)}"
|
|
116
|
+
return url
|
|
117
|
+
|
|
118
|
+
def _refresh_token(self) -> None:
|
|
119
|
+
data = self.request(
|
|
120
|
+
"POST",
|
|
121
|
+
"/auth/refresh",
|
|
122
|
+
body={"refresh_token": self.config.refresh_token},
|
|
123
|
+
auth=False,
|
|
124
|
+
)
|
|
125
|
+
access_token = data.get("access_token")
|
|
126
|
+
refresh_token = data.get("refresh_token")
|
|
127
|
+
if not access_token or not refresh_token:
|
|
128
|
+
raise ApiError("Refresh response did not include tokens", status=401)
|
|
129
|
+
save_values({"access_token": access_token, "refresh_token": refresh_token})
|
|
130
|
+
self.config = replace(
|
|
131
|
+
self.config,
|
|
132
|
+
access_token=access_token,
|
|
133
|
+
refresh_token=refresh_token,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _parse_response(self, status: int, response_body: bytes) -> Any:
|
|
137
|
+
if not response_body:
|
|
138
|
+
return None
|
|
139
|
+
try:
|
|
140
|
+
payload = json.loads(response_body.decode("utf-8"))
|
|
141
|
+
except json.JSONDecodeError as exc:
|
|
142
|
+
raise ApiError("Control Server returned invalid JSON", status=status) from exc
|
|
143
|
+
|
|
144
|
+
if isinstance(payload, dict) and "success" in payload and "data" in payload:
|
|
145
|
+
if not payload.get("success"):
|
|
146
|
+
error = payload.get("error") or {}
|
|
147
|
+
meta = payload.get("meta") or {}
|
|
148
|
+
message = error.get("message") or meta.get("message") or "Request failed"
|
|
149
|
+
code = error.get("code") or meta.get("code")
|
|
150
|
+
raise ApiError(message, status=status, code=code)
|
|
151
|
+
return payload.get("data")
|
|
152
|
+
return payload
|
|
153
|
+
|
|
154
|
+
def _raise_api_error(self, status: int, response_body: bytes) -> None:
|
|
155
|
+
if not response_body:
|
|
156
|
+
raise ApiError("Request failed", status=status)
|
|
157
|
+
try:
|
|
158
|
+
payload = json.loads(response_body.decode("utf-8"))
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
raise ApiError(response_body.decode("utf-8", errors="replace"), status=status) from exc
|
|
161
|
+
|
|
162
|
+
if isinstance(payload, dict):
|
|
163
|
+
error = payload.get("error") or {}
|
|
164
|
+
meta = payload.get("meta") or {}
|
|
165
|
+
detail = payload.get("detail")
|
|
166
|
+
message = (
|
|
167
|
+
error.get("message")
|
|
168
|
+
or meta.get("message")
|
|
169
|
+
or (detail if isinstance(detail, str) else None)
|
|
170
|
+
or "Request failed"
|
|
171
|
+
)
|
|
172
|
+
code = error.get("code") or meta.get("code")
|
|
173
|
+
raise ApiError(message, status=status, code=code)
|
|
174
|
+
|
|
175
|
+
raise ApiError("Request failed", status=status)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Command group registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from . import agents, auth, config, jobs, runs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
11
|
+
config.register(subparsers)
|
|
12
|
+
auth.register(subparsers)
|
|
13
|
+
jobs.register(subparsers)
|
|
14
|
+
runs.register(subparsers)
|
|
15
|
+
agents.register(subparsers, name="agents")
|
|
16
|
+
agents.register(subparsers, name="nodes")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Agent and node management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..context import Context
|
|
9
|
+
from ..formatting import compact_id, print_json, print_kv, print_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers: argparse._SubParsersAction, *, name: str) -> None:
|
|
13
|
+
help_text = "Manage agents through Control Server" if name == "agents" else "Alias for agents"
|
|
14
|
+
parser = subparsers.add_parser(name, help=help_text)
|
|
15
|
+
agent_subparsers = parser.add_subparsers(dest=f"{name}_command", required=True)
|
|
16
|
+
|
|
17
|
+
list_parser = agent_subparsers.add_parser("list", help="List agents")
|
|
18
|
+
list_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
19
|
+
list_parser.set_defaults(func=list_agents)
|
|
20
|
+
|
|
21
|
+
gpus_parser = agent_subparsers.add_parser("gpus", help="List GPUs globally or for one agent")
|
|
22
|
+
gpus_parser.add_argument("agent_id", nargs="?")
|
|
23
|
+
gpus_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
24
|
+
gpus_parser.set_defaults(func=list_gpus)
|
|
25
|
+
|
|
26
|
+
queue_parser = agent_subparsers.add_parser("queue", help="Get an agent queue snapshot")
|
|
27
|
+
queue_parser.add_argument("agent_id")
|
|
28
|
+
queue_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
29
|
+
queue_parser.set_defaults(func=get_queue)
|
|
30
|
+
|
|
31
|
+
jobs_parser = agent_subparsers.add_parser("jobs", help="List jobs currently known by an agent")
|
|
32
|
+
jobs_parser.add_argument("agent_id")
|
|
33
|
+
jobs_parser.add_argument("--status", help="Comma-separated status filter, e.g. queued,running")
|
|
34
|
+
jobs_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
35
|
+
jobs_parser.set_defaults(func=list_agent_jobs)
|
|
36
|
+
|
|
37
|
+
rename_parser = agent_subparsers.add_parser("rename", help="Update an agent nickname")
|
|
38
|
+
rename_parser.add_argument("agent_id")
|
|
39
|
+
rename_parser.add_argument("nickname")
|
|
40
|
+
rename_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
41
|
+
rename_parser.set_defaults(func=rename_agent)
|
|
42
|
+
|
|
43
|
+
interval_parser = agent_subparsers.add_parser("set-gpu-interval", help="Set GPU monitor interval")
|
|
44
|
+
interval_parser.add_argument("agent_id")
|
|
45
|
+
interval_parser.add_argument("interval", type=float)
|
|
46
|
+
interval_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
47
|
+
interval_parser.set_defaults(func=set_gpu_interval)
|
|
48
|
+
|
|
49
|
+
delete_parser = agent_subparsers.add_parser("delete", help="Remove an agent from current user")
|
|
50
|
+
delete_parser.add_argument("agent_id")
|
|
51
|
+
delete_parser.add_argument("-y", "--yes", action="store_true", help="Do not prompt for confirmation")
|
|
52
|
+
delete_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
53
|
+
delete_parser.set_defaults(func=delete_agent)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_agents(args: argparse.Namespace, ctx: Context) -> int:
|
|
57
|
+
data = ctx.client().get("/agents")
|
|
58
|
+
if args.json:
|
|
59
|
+
print_json(data)
|
|
60
|
+
return 0
|
|
61
|
+
agents = data.get("agents", [])
|
|
62
|
+
print_table(
|
|
63
|
+
agents,
|
|
64
|
+
[
|
|
65
|
+
("ID", lambda row: compact_id(row.get("agent_id"))),
|
|
66
|
+
("NAME", lambda row: row.get("nickname") or row.get("agent_id")),
|
|
67
|
+
("ONLINE", "is_online"),
|
|
68
|
+
("ROLE", "role"),
|
|
69
|
+
("GPU", "has_gpu_info"),
|
|
70
|
+
("CPU%", "cpu_percent"),
|
|
71
|
+
("MEM%", "memory_percent"),
|
|
72
|
+
("LAST SEEN", "last_seen_at"),
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
total = data.get("total")
|
|
76
|
+
if total is not None:
|
|
77
|
+
print(f"\nTotal: {total}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_gpus(args: argparse.Namespace, ctx: Context) -> int:
|
|
82
|
+
if args.agent_id:
|
|
83
|
+
data = ctx.client().get(f"/agents/{args.agent_id}/gpus")
|
|
84
|
+
rows = data.get("gpus", [])
|
|
85
|
+
else:
|
|
86
|
+
data = ctx.client().get("/agents/gpus")
|
|
87
|
+
rows = [
|
|
88
|
+
{
|
|
89
|
+
"agent_id": item.get("agent_id"),
|
|
90
|
+
"agent_nickname": item.get("agent_nickname"),
|
|
91
|
+
**(item.get("gpu") or {}),
|
|
92
|
+
}
|
|
93
|
+
for item in data.get("gpus", [])
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
if args.json:
|
|
97
|
+
print_json(data)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
columns: list[tuple[str, Any]] = []
|
|
101
|
+
if not args.agent_id:
|
|
102
|
+
columns.append(("AGENT", lambda row: row.get("agent_nickname") or compact_id(row.get("agent_id"))))
|
|
103
|
+
columns.extend(
|
|
104
|
+
[
|
|
105
|
+
("IDX", "index"),
|
|
106
|
+
("NAME", "name"),
|
|
107
|
+
("UTIL%", "utilization"),
|
|
108
|
+
("MEM USED", "memory_used"),
|
|
109
|
+
("MEM TOTAL", "memory_total"),
|
|
110
|
+
("TEMP", "temperature"),
|
|
111
|
+
("POWER", "power_draw"),
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
print_table(rows, columns)
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_queue(args: argparse.Namespace, ctx: Context) -> int:
|
|
119
|
+
data = ctx.client().get(f"/agents/{args.agent_id}/queue")
|
|
120
|
+
if args.json:
|
|
121
|
+
print_json(data)
|
|
122
|
+
return 0
|
|
123
|
+
entries = data.get("entries", [])
|
|
124
|
+
print(f"Timestamp: {data.get('timestamp') or '-'}")
|
|
125
|
+
print_table(
|
|
126
|
+
entries,
|
|
127
|
+
[
|
|
128
|
+
("TASK", lambda row: compact_id(row.get("task_id") or row.get("id"))),
|
|
129
|
+
("STATUS", "status"),
|
|
130
|
+
("PRIORITY", "priority"),
|
|
131
|
+
("CREATED", "created_at"),
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_agent_jobs(args: argparse.Namespace, ctx: Context) -> int:
|
|
138
|
+
data = ctx.client().get(
|
|
139
|
+
f"/agents/{args.agent_id}/jobs",
|
|
140
|
+
query={"status": args.status},
|
|
141
|
+
)
|
|
142
|
+
if args.json:
|
|
143
|
+
print_json(data)
|
|
144
|
+
return 0
|
|
145
|
+
jobs = data.get("jobs", [])
|
|
146
|
+
print(f"Timestamp: {data.get('timestamp') or '-'}")
|
|
147
|
+
print_table(
|
|
148
|
+
jobs,
|
|
149
|
+
[
|
|
150
|
+
("ID", lambda row: compact_id(row.get("task_id") or row.get("job_id") or row.get("id"))),
|
|
151
|
+
("STATUS", "status"),
|
|
152
|
+
("COMMAND", lambda row: row.get("command") or row.get("cmd") or row.get("name")),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def rename_agent(args: argparse.Namespace, ctx: Context) -> int:
|
|
159
|
+
data = ctx.client().patch(
|
|
160
|
+
f"/agents/{args.agent_id}/nickname",
|
|
161
|
+
body={"nickname": args.nickname},
|
|
162
|
+
)
|
|
163
|
+
_print_action_or_json(args, data)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_gpu_interval(args: argparse.Namespace, ctx: Context) -> int:
|
|
168
|
+
data = ctx.client().post(
|
|
169
|
+
f"/agents/{args.agent_id}/gpu-monitor-interval",
|
|
170
|
+
body={"interval": args.interval},
|
|
171
|
+
)
|
|
172
|
+
_print_action_or_json(args, data)
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def delete_agent(args: argparse.Namespace, ctx: Context) -> int:
|
|
177
|
+
if not args.yes:
|
|
178
|
+
confirmation = input(f"Delete agent {args.agent_id}? Type 'yes' to continue: ")
|
|
179
|
+
if confirmation != "yes":
|
|
180
|
+
print("Aborted.")
|
|
181
|
+
return 1
|
|
182
|
+
data = ctx.client().delete(f"/agents/{args.agent_id}")
|
|
183
|
+
_print_action_or_json(args, data)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _print_action_or_json(args: argparse.Namespace, data: dict[str, Any]) -> None:
|
|
188
|
+
if args.json:
|
|
189
|
+
print_json(data)
|
|
190
|
+
else:
|
|
191
|
+
print_kv(data)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Authentication commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from ..client import ApiClient
|
|
10
|
+
from ..config import load_config, save_values, unset_values
|
|
11
|
+
from ..context import Context
|
|
12
|
+
from ..formatting import print_json, print_kv
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
16
|
+
parser = subparsers.add_parser("auth", help="Authenticate with Control Server")
|
|
17
|
+
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
18
|
+
|
|
19
|
+
login_parser = auth_subparsers.add_parser("login", help="Log in and store tokens")
|
|
20
|
+
login_parser.add_argument("-u", "--username", help="Username")
|
|
21
|
+
login_parser.add_argument("-p", "--password", help="Password")
|
|
22
|
+
login_parser.add_argument("--base-url", help="Control Server base URL")
|
|
23
|
+
login_parser.add_argument("--endpoint-name", help="Endpoint name recorded by Control Server")
|
|
24
|
+
login_parser.add_argument("--language", default="en", help="Language for auth context")
|
|
25
|
+
login_parser.add_argument("--timezone", default="UTC", help="Timezone for auth context")
|
|
26
|
+
login_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
27
|
+
login_parser.set_defaults(func=login)
|
|
28
|
+
|
|
29
|
+
refresh_parser = auth_subparsers.add_parser("refresh", help="Refresh stored access token")
|
|
30
|
+
refresh_parser.set_defaults(func=refresh)
|
|
31
|
+
|
|
32
|
+
me_parser = auth_subparsers.add_parser("me", help="Show current user")
|
|
33
|
+
me_parser.add_argument("--json", action="store_true", help="Print raw response JSON")
|
|
34
|
+
me_parser.set_defaults(func=me)
|
|
35
|
+
|
|
36
|
+
logout_parser = auth_subparsers.add_parser("logout", help="Clear locally stored tokens")
|
|
37
|
+
logout_parser.set_defaults(func=logout)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def login(args: argparse.Namespace, ctx: Context) -> int:
|
|
41
|
+
username = args.username or input("Username: ").strip()
|
|
42
|
+
password = args.password or getpass.getpass("Password: ")
|
|
43
|
+
|
|
44
|
+
base_config = load_config(base_url=args.base_url)
|
|
45
|
+
endpoint_name = args.endpoint_name or base_config.endpoint_name
|
|
46
|
+
config = replace(base_config, endpoint_name=endpoint_name)
|
|
47
|
+
client = ApiClient(config)
|
|
48
|
+
|
|
49
|
+
data = client.post(
|
|
50
|
+
"/auth/login",
|
|
51
|
+
body={
|
|
52
|
+
"username": username,
|
|
53
|
+
"password": password,
|
|
54
|
+
"endpoint_id": config.endpoint_id,
|
|
55
|
+
"endpoint_name": config.endpoint_name,
|
|
56
|
+
"language": args.language,
|
|
57
|
+
"timezone": args.timezone,
|
|
58
|
+
},
|
|
59
|
+
auth=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if data.get("requires_2fa"):
|
|
63
|
+
print("Login requires 2FA. CLI 2FA completion is not implemented yet.")
|
|
64
|
+
return 1
|
|
65
|
+
if not data.get("access_token") or not data.get("refresh_token"):
|
|
66
|
+
print("Login response did not include access and refresh tokens.")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
values = {
|
|
70
|
+
"base_url": config.base_url,
|
|
71
|
+
"access_token": data.get("access_token"),
|
|
72
|
+
"refresh_token": data.get("refresh_token"),
|
|
73
|
+
"endpoint_id": config.endpoint_id,
|
|
74
|
+
"endpoint_name": config.endpoint_name,
|
|
75
|
+
"user": data.get("user"),
|
|
76
|
+
}
|
|
77
|
+
save_values(values)
|
|
78
|
+
|
|
79
|
+
if args.json:
|
|
80
|
+
print_json(data)
|
|
81
|
+
else:
|
|
82
|
+
user = data.get("user") or {}
|
|
83
|
+
print("Login succeeded.")
|
|
84
|
+
print_kv(
|
|
85
|
+
{
|
|
86
|
+
"user": user.get("username") or user.get("id") or username,
|
|
87
|
+
"base_url": config.base_url,
|
|
88
|
+
"endpoint_id": config.endpoint_id,
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def refresh(args: argparse.Namespace, ctx: Context) -> int:
|
|
95
|
+
if not ctx.config.refresh_token:
|
|
96
|
+
print("No refresh token configured. Run `fluxhivectl auth login` first.")
|
|
97
|
+
return 1
|
|
98
|
+
data = ctx.client().post(
|
|
99
|
+
"/auth/refresh",
|
|
100
|
+
body={"refresh_token": ctx.config.refresh_token},
|
|
101
|
+
auth=False,
|
|
102
|
+
)
|
|
103
|
+
save_values(
|
|
104
|
+
{
|
|
105
|
+
"access_token": data.get("access_token"),
|
|
106
|
+
"refresh_token": data.get("refresh_token"),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
print("Token refreshed.")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def me(args: argparse.Namespace, ctx: Context) -> int:
|
|
114
|
+
data = ctx.client().get("/auth/me")
|
|
115
|
+
if args.json:
|
|
116
|
+
print_json(data)
|
|
117
|
+
else:
|
|
118
|
+
print_kv(
|
|
119
|
+
{
|
|
120
|
+
"id": data.get("id"),
|
|
121
|
+
"username": data.get("username"),
|
|
122
|
+
"display_name": data.get("display_name"),
|
|
123
|
+
"active": data.get("is_active"),
|
|
124
|
+
"verified_email": data.get("has_verified_email"),
|
|
125
|
+
"verified_phone": data.get("has_verified_phone"),
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def logout(args: argparse.Namespace, ctx: Context) -> int:
|
|
132
|
+
unset_values(["access_token", "refresh_token", "user"])
|
|
133
|
+
print("Local tokens cleared.")
|
|
134
|
+
return 0
|