nexinal 1.0.2__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.
nexinal/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """nexinal - CLI client for Nexuss Bash remote execution API."""
2
+
3
+ __version__ = "1.0.2"
4
+ __app_name__ = "nexinal"
nexinal/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running nexinal as a module: python -m nexinal."""
2
+
3
+ from nexinal.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
nexinal/api.py ADDED
@@ -0,0 +1,149 @@
1
+ """API client for Nexuss Bash."""
2
+
3
+ import json
4
+ from typing import Optional, Any
5
+
6
+ import requests
7
+ from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
8
+
9
+ from nexinal.config import get_api_url, get_token
10
+
11
+
12
+ class APIError(Exception):
13
+ def __init__(self, message: str, status_code: Optional[int] = None):
14
+ self.message = message
15
+ self.status_code = status_code
16
+ super().__init__(self.message)
17
+
18
+
19
+ class AuthError(APIError):
20
+ pass
21
+
22
+
23
+ class ConnectionError_(APIError):
24
+ pass
25
+
26
+
27
+ def _build_headers() -> dict:
28
+ headers = {"Content-Type": "application/json"}
29
+ token = get_token()
30
+ if token:
31
+ headers["Authorization"] = f"Bearer {token}"
32
+ return headers
33
+
34
+
35
+ def _build_url(path: str) -> str:
36
+ base = get_api_url().rstrip("/")
37
+ path = path.lstrip("/")
38
+ return f"{base}/{path}"
39
+
40
+
41
+ def api_get(path: str) -> dict:
42
+ url = _build_url(path)
43
+ try:
44
+ resp = requests.get(url, headers=_build_headers(), timeout=30)
45
+ resp.raise_for_status()
46
+ return resp.json()
47
+ except HTTPError as e:
48
+ status = e.response.status_code if e.response is not None else None
49
+ if status == 401:
50
+ raise AuthError("Authentication failed. Run: nexinal auth <token>", status)
51
+ if status == 404:
52
+ raise APIError(f"Not found: {path}", status)
53
+ raise APIError(f"HTTP {status}: {e}", status)
54
+ except ConnectionError:
55
+ raise ConnectionError_(f"Cannot connect to {url}. Check your network and API URL.")
56
+ except Timeout:
57
+ raise APIError(f"Request timed out: {url}")
58
+ except RequestException as e:
59
+ raise APIError(f"Request failed: {e}")
60
+ except json.JSONDecodeError:
61
+ raise APIError("Invalid JSON response from server")
62
+
63
+
64
+ def api_post(path: str, data: Optional[dict] = None) -> dict:
65
+ url = _build_url(path)
66
+ try:
67
+ resp = requests.post(url, headers=_build_headers(), json=data or {}, timeout=30)
68
+ resp.raise_for_status()
69
+ return resp.json()
70
+ except HTTPError as e:
71
+ status = e.response.status_code if e.response is not None else None
72
+ if status == 401:
73
+ raise AuthError("Authentication failed. Run: nexinal auth <token>", status)
74
+ if status == 404:
75
+ raise APIError(f"Not found: {path}", status)
76
+ body = ""
77
+ if e.response is not None:
78
+ try:
79
+ body = e.response.json().get("detail", e.response.text)
80
+ except Exception:
81
+ body = e.response.text
82
+ raise APIError(f"HTTP {status}: {body}", status)
83
+ except ConnectionError:
84
+ raise ConnectionError_(f"Cannot connect to {url}. Check your network and API URL.")
85
+ except Timeout:
86
+ raise APIError(f"Request timed out: {url}")
87
+ except RequestException as e:
88
+ raise APIError(f"Request failed: {e}")
89
+ except json.JSONDecodeError:
90
+ raise APIError("Invalid JSON response from server")
91
+
92
+
93
+ def api_post_file(path: str, file_path: str) -> dict:
94
+ url = _build_url(path)
95
+ token = get_token()
96
+ headers = {}
97
+ if token:
98
+ headers["Authorization"] = f"Bearer {token}"
99
+ try:
100
+ with open(file_path, "rb") as f:
101
+ files = {"file": (file_path.split("/")[-1], f)}
102
+ resp = requests.post(url, headers=headers, files=files, timeout=60)
103
+ resp.raise_for_status()
104
+ return resp.json()
105
+ except HTTPError as e:
106
+ status = e.response.status_code if e.response is not None else None
107
+ if status == 401:
108
+ raise AuthError("Authentication failed. Run: nexinal auth <token>", status)
109
+ raise APIError(f"HTTP {status}: {e}", status)
110
+ except ConnectionError:
111
+ raise ConnectionError_(f"Cannot connect to {url}. Check your network and API URL.")
112
+ except Timeout:
113
+ raise APIError(f"Request timed out: {url}")
114
+ except RequestException as e:
115
+ raise APIError(f"Request failed: {e}")
116
+ except FileNotFoundError:
117
+ raise APIError(f"File not found: {file_path}")
118
+ except json.JSONDecodeError:
119
+ raise APIError("Invalid JSON response from server")
120
+
121
+
122
+ def api_delete(path: str) -> dict:
123
+ url = _build_url(path)
124
+ try:
125
+ resp = requests.delete(url, headers=_build_headers(), timeout=30)
126
+ resp.raise_for_status()
127
+ return resp.json()
128
+ except HTTPError as e:
129
+ status = e.response.status_code if e.response is not None else None
130
+ if status == 401:
131
+ raise AuthError("Authentication failed. Run: nexinal auth <token>", status)
132
+ raise APIError(f"HTTP {status}: {e}", status)
133
+ except ConnectionError:
134
+ raise ConnectionError_(f"Cannot connect to {url}. Check your network and API URL.")
135
+ except Timeout:
136
+ raise APIError(f"Request timed out: {url}")
137
+ except RequestException as e:
138
+ raise APIError(f"Request failed: {e}")
139
+ except json.JSONDecodeError:
140
+ raise APIError("Invalid JSON response from server")
141
+
142
+
143
+ def test_connection() -> bool:
144
+ try:
145
+ url = _build_url("/health")
146
+ resp = requests.get(url, timeout=10)
147
+ return resp.status_code == 200
148
+ except Exception:
149
+ return False
nexinal/cli.py ADDED
@@ -0,0 +1,359 @@
1
+ """Main Click CLI for nexinal."""
2
+
3
+ import sys
4
+ from typing import List
5
+
6
+ import click
7
+
8
+ from nexinal import __version__
9
+ from nexinal.config import (
10
+ load_config, save_config, get_token, get_api_url,
11
+ set_token, set_api_url, remove_token, get_last_run_id,
12
+ )
13
+ from nexinal.api import api_get, api_post, APIError, AuthError, ConnectionError_, test_connection
14
+ from nexinal.runner import run_commands
15
+ from nexinal.yaml_parser import parse_yaml_file, YAMLError, CommandEntry
16
+ from nexinal.display import (
17
+ console, print_banner, print_result, print_error,
18
+ print_success, print_warning, print_info, print_status,
19
+ create_results_table,
20
+ )
21
+
22
+
23
+ @click.group()
24
+ @click.version_option(version=__version__, prog_name="nexinal")
25
+ def main():
26
+ """nexinal - CLI client for Nexuss Bash remote execution API."""
27
+ pass
28
+
29
+
30
+ @main.command()
31
+ @click.argument("token")
32
+ def auth(token: str):
33
+ """Save authentication token and verify it."""
34
+ print_info("Verifying connection...")
35
+
36
+ old_token = get_token()
37
+ set_token(token)
38
+
39
+ try:
40
+ resp = api_get("/health")
41
+ server_name = resp.get("name", resp.get("server_name", "Nexuss Bash"))
42
+ print_success(f"Connected to {server_name}")
43
+ print_success(f"Token saved to ~/.nexinal/config.json")
44
+ except ConnectionError_ as e:
45
+ set_token(old_token or "")
46
+ if old_token is None:
47
+ remove_token()
48
+ print_error(f"Cannot reach server: {e}")
49
+ sys.exit(1)
50
+ except APIError as e:
51
+ set_token(old_token or "")
52
+ if old_token is None:
53
+ remove_token()
54
+ print_error(f"Connection check failed: {e}")
55
+ sys.exit(1)
56
+ except AuthError as e:
57
+ set_token(old_token or "")
58
+ if old_token is None:
59
+ remove_token()
60
+ print_error(f"Auth failed: {e}")
61
+ sys.exit(1)
62
+ except Exception as e:
63
+ set_token(old_token or "")
64
+ if old_token is None:
65
+ remove_token()
66
+ print_error(f"Unexpected error: {e}")
67
+ sys.exit(1)
68
+
69
+
70
+ @main.command()
71
+ @click.argument("commands", nargs=-1, required=True)
72
+ @click.option("--stop-on-fail", is_flag=True, help="Stop on first failed command")
73
+ def run(commands: tuple, stop_on_fail: bool):
74
+ """Run commands sequentially on the remote server.
75
+
76
+ Each argument is a separate command to execute.
77
+
78
+ Example: nexinal run "echo hello" "whoami" "ls /workspace"
79
+ """
80
+ if not commands:
81
+ print_error("No commands provided")
82
+ sys.exit(1)
83
+
84
+ result = run_commands(list(commands), stop_on_fail=stop_on_fail)
85
+ if not result.success:
86
+ sys.exit(1)
87
+
88
+
89
+ @main.command()
90
+ @click.argument("yaml_file")
91
+ @click.option("--stop-on-fail", is_flag=True, help="Stop on first failed command")
92
+ def execute(yaml_file: str, stop_on_fail: bool):
93
+ """Execute commands from a YAML file.
94
+
95
+ Supports both simple (list of strings) and detailed (objects) formats.
96
+ """
97
+ try:
98
+ commands = parse_yaml_file(yaml_file)
99
+ except YAMLError as e:
100
+ print_error(str(e))
101
+ sys.exit(1)
102
+
103
+ print_info(f"Loaded {len(commands)} command(s) from {yaml_file}")
104
+ result = run_commands(commands, stop_on_fail=stop_on_fail)
105
+ if not result.success:
106
+ sys.exit(1)
107
+
108
+
109
+ @main.command()
110
+ def status():
111
+ """Show connection status and configuration info."""
112
+ token = get_token()
113
+ api_url = get_api_url()
114
+ last_run_id = get_last_run_id()
115
+
116
+ connected = test_connection()
117
+
118
+ authenticated = False
119
+ if token and connected:
120
+ try:
121
+ api_get("/health")
122
+ authenticated = True
123
+ except AuthError:
124
+ authenticated = False
125
+ except Exception:
126
+ authenticated = False
127
+
128
+ print_status({
129
+ "connected": connected,
130
+ "api_url": api_url,
131
+ "token": token,
132
+ "authenticated": authenticated,
133
+ "last_run_id": last_run_id,
134
+ })
135
+
136
+
137
+ @main.command()
138
+ def history():
139
+ """List past runs."""
140
+ if not get_token():
141
+ print_error("Not authenticated. Run: nexinal auth <token>")
142
+ sys.exit(1)
143
+
144
+ try:
145
+ resp = api_get("/run")
146
+ except APIError as e:
147
+ print_error(str(e))
148
+ sys.exit(1)
149
+ except ConnectionError_ as e:
150
+ print_error(str(e))
151
+ sys.exit(1)
152
+
153
+ runs = resp if isinstance(resp, list) else resp.get("runs", resp.get("data", []))
154
+
155
+ if not runs:
156
+ print_info("No runs found")
157
+ return
158
+
159
+ from rich.table import Table
160
+ from rich import box
161
+
162
+ table = Table(title="Run History", box=box.ROUNDED, show_lines=True)
163
+ table.add_column("Run ID", style="bold cyan", max_width=12)
164
+ table.add_column("Status", justify="center", width=10)
165
+ table.add_column("Commands", justify="center", width=10)
166
+ table.add_column("Created", style="dim")
167
+
168
+ for run_entry in runs:
169
+ run_id = str(run_entry.get("id", run_entry.get("run_id", "-")))
170
+ if len(run_id) > 12:
171
+ run_id = run_id[:9] + "..."
172
+ run_status = run_entry.get("status", "unknown")
173
+ num_commands = run_entry.get("num_commands", len(run_entry.get("commands", [])))
174
+ created = run_entry.get("created_at", run_entry.get("timestamp", "-"))
175
+
176
+ if run_status in ("completed", "success", "passed"):
177
+ status_display = Text("✓ " + run_status, style="green")
178
+ elif run_status in ("failed", "error"):
179
+ status_display = Text("✗ " + run_status, style="red")
180
+ else:
181
+ status_display = Text(run_status, style="yellow")
182
+
183
+ table.add_row(run_id, status_display, str(num_commands), str(created))
184
+
185
+ console.print(table)
186
+
187
+
188
+ @main.command()
189
+ def health():
190
+ """Quick health check against the API server."""
191
+ api_url = get_api_url()
192
+ print_info(f"Checking {api_url}...")
193
+
194
+ if test_connection():
195
+ try:
196
+ resp = api_get("/health")
197
+ server_name = resp.get("name", resp.get("server_name", "Nexuss Bash"))
198
+ version = resp.get("version", "unknown")
199
+ print_success(f"{server_name} is healthy (v{version})")
200
+ except Exception:
201
+ print_success(f"Server at {api_url} is reachable")
202
+ else:
203
+ print_error(f"Server at {api_url} is not reachable")
204
+ sys.exit(1)
205
+
206
+
207
+ @main.command()
208
+ def sessions():
209
+ """List active sessions."""
210
+ if not get_token():
211
+ print_error("Not authenticated. Run: nexinal auth <token>")
212
+ sys.exit(1)
213
+
214
+ try:
215
+ resp = api_get("/sessions")
216
+ except APIError as e:
217
+ print_error(str(e))
218
+ sys.exit(1)
219
+ except ConnectionError_ as e:
220
+ print_error(str(e))
221
+ sys.exit(1)
222
+
223
+ session_list = resp if isinstance(resp, list) else resp.get("sessions", resp.get("data", []))
224
+
225
+ if not session_list:
226
+ print_info("No active sessions")
227
+ return
228
+
229
+ from rich.table import Table
230
+ from rich import box
231
+
232
+ table = Table(title="Active Sessions", box=box.ROUNDED, show_lines=True)
233
+ table.add_column("Session ID", style="bold cyan", max_width=16)
234
+ table.add_column("Status", justify="center", width=10)
235
+ table.add_column("Created", style="dim")
236
+
237
+ for sess in session_list:
238
+ sess_id = str(sess.get("id", sess.get("session_id", "-")))
239
+ if len(sess_id) > 16:
240
+ sess_id = sess_id[:13] + "..."
241
+ sess_status = sess.get("status", "active")
242
+ created = sess.get("created_at", "-")
243
+
244
+ table.add_row(sess_id, sess_status, str(created))
245
+
246
+ console.print(table)
247
+
248
+
249
+ @main.group()
250
+ def packages():
251
+ """Manage installed packages."""
252
+ pass
253
+
254
+
255
+ @packages.command("list")
256
+ def packages_list():
257
+ """List installed packages."""
258
+ if not get_token():
259
+ print_error("Not authenticated. Run: nexinal auth <token>")
260
+ sys.exit(1)
261
+
262
+ try:
263
+ resp = api_get("/packages")
264
+ except APIError as e:
265
+ print_error(str(e))
266
+ sys.exit(1)
267
+ except ConnectionError_ as e:
268
+ print_error(str(e))
269
+ sys.exit(1)
270
+
271
+ pkgs = resp if isinstance(resp, list) else resp.get("packages", resp.get("data", []))
272
+
273
+ if not pkgs:
274
+ print_info("No packages installed")
275
+ return
276
+
277
+ from rich.table import Table
278
+ from rich import box
279
+
280
+ table = Table(title="Installed Packages", box=box.ROUNDED, show_lines=True)
281
+ table.add_column("Name", style="bold", ratio=2)
282
+ table.add_column("Version", style="dim", ratio=1)
283
+ table.add_column("Manager", justify="center", width=10)
284
+
285
+ for pkg in pkgs:
286
+ name = pkg.get("name", "?")
287
+ version = pkg.get("version", "-")
288
+ manager = pkg.get("manager", pkg.get("package_manager", "-"))
289
+ table.add_row(str(name), str(version), str(manager))
290
+
291
+ console.print(table)
292
+
293
+
294
+ @packages.command("install")
295
+ @click.argument("name")
296
+ @click.option("--manager", type=click.Choice(["apt", "pip", "npm"]), required=True, help="Package manager to use")
297
+ def packages_install(name: str, manager: str):
298
+ """Install a package on the remote server."""
299
+ if not get_token():
300
+ print_error("Not authenticated. Run: nexinal auth <token>")
301
+ sys.exit(1)
302
+
303
+ print_info(f"Installing {name} via {manager}...")
304
+
305
+ try:
306
+ resp = api_post("/packages/install", {"name": name, "manager": manager})
307
+ output = resp.get("output", resp.get("detail", ""))
308
+ if output:
309
+ console.print(output)
310
+ print_success(f"Installed {name} via {manager}")
311
+ except APIError as e:
312
+ print_error(str(e))
313
+ sys.exit(1)
314
+ except ConnectionError_ as e:
315
+ print_error(str(e))
316
+ sys.exit(1)
317
+
318
+
319
+ @main.command()
320
+ @click.option("--set", "set_url", help="Set a new API URL")
321
+ @click.option("--show", "show_url", is_flag=True, help="Show current API URL")
322
+ def config(set_url: str, show_url: bool):
323
+ """Show or set API URL configuration."""
324
+ if set_url:
325
+ set_api_url(set_url)
326
+ print_success(f"API URL set to {set_url}")
327
+ return
328
+
329
+ api_url = get_api_url()
330
+ token = get_token()
331
+ token_display = f"{token[:8]}...{token[-4:]}" if token and len(token) > 12 else (token if token else "Not set")
332
+
333
+ from rich.table import Table
334
+ from rich import box
335
+
336
+ table = Table(title="Configuration", box=box.SIMPLE_HEAVY, show_header=False)
337
+ table.add_column("Key", style="bold cyan", width=16)
338
+ table.add_column("Value")
339
+ table.add_row("API URL", api_url)
340
+ table.add_row("Token", token_display)
341
+ table.add_row("Config File", str(load_config.__module__))
342
+
343
+ console.print(table)
344
+
345
+
346
+ @main.command()
347
+ def logout():
348
+ """Remove saved authentication token."""
349
+ token = get_token()
350
+ if not token:
351
+ print_warning("No token is currently saved")
352
+ return
353
+
354
+ remove_token()
355
+ print_success("Token removed. You have been logged out.")
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
nexinal/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Configuration manager for nexinal CLI."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ CONFIG_DIR = Path.home() / ".nexinal"
9
+ CONFIG_FILE = CONFIG_DIR / "config.json"
10
+
11
+ DEFAULT_API_URL = "https://nexuss-bash.onrender.com"
12
+
13
+
14
+ def ensure_config_dir() -> None:
15
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
16
+
17
+
18
+ def load_config() -> dict:
19
+ if not CONFIG_FILE.exists():
20
+ return {"api_url": DEFAULT_API_URL, "token": None, "last_run_id": None}
21
+ try:
22
+ with open(CONFIG_FILE, "r") as f:
23
+ return json.load(f)
24
+ except (json.JSONDecodeError, IOError):
25
+ return {"api_url": DEFAULT_API_URL, "token": None, "last_run_id": None}
26
+
27
+
28
+ def save_config(config: dict) -> None:
29
+ ensure_config_dir()
30
+ with open(CONFIG_FILE, "w") as f:
31
+ json.dump(config, f, indent=2)
32
+
33
+
34
+ def get_token() -> Optional[str]:
35
+ config = load_config()
36
+ return config.get("token")
37
+
38
+
39
+ def get_api_url() -> str:
40
+ config = load_config()
41
+ return config.get("api_url", DEFAULT_API_URL)
42
+
43
+
44
+ def set_token(token: str) -> None:
45
+ config = load_config()
46
+ config["token"] = token
47
+ save_config(config)
48
+
49
+
50
+ def set_api_url(url: str) -> None:
51
+ config = load_config()
52
+ config["api_url"] = url.rstrip("/")
53
+ save_config(config)
54
+
55
+
56
+ def set_last_run_id(run_id: str) -> None:
57
+ config = load_config()
58
+ config["last_run_id"] = run_id
59
+ save_config(config)
60
+
61
+
62
+ def get_last_run_id() -> Optional[str]:
63
+ config = load_config()
64
+ return config.get("last_run_id")
65
+
66
+
67
+ def remove_token() -> None:
68
+ config = load_config()
69
+ config["token"] = None
70
+ save_config(config)
71
+
72
+
73
+ def clear_config() -> None:
74
+ if CONFIG_FILE.exists():
75
+ CONFIG_FILE.unlink()
nexinal/display.py ADDED
@@ -0,0 +1,187 @@
1
+ """Rich display utilities for nexinal CLI."""
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+ from rich.columns import Columns
8
+ from rich import box
9
+
10
+ console = Console()
11
+
12
+ BANNER = r"""
13
+ _ _
14
+ (_) | |
15
+ _ __ _ __ _ __ _ ___ __| |
16
+ | '__|| '__|| '_ \| |/ __|/ _` |
17
+ | | | | | |_) | | (__| (_| |
18
+ |_| |_| | .__/|_|\___|\__,_|
19
+ | |
20
+ |_| v1.0.0
21
+ """
22
+
23
+
24
+ def print_banner() -> None:
25
+ text = Text(BANNER, style="bold cyan")
26
+ console.print(text)
27
+
28
+
29
+ def print_result(result: dict) -> None:
30
+ command = result.get("command", "unknown")
31
+ status = result.get("status", "unknown")
32
+ output = result.get("stdout", result.get("output", ""))
33
+ exit_code = result.get("exit_code", None)
34
+ duration_ms = result.get("duration_ms", result.get("duration", None))
35
+ duration_s = round(duration_ms / 1000, 2) if isinstance(duration_ms, (int, float)) and duration_ms > 100 else duration_ms
36
+
37
+ if status == "passed" or status == "success" or status == "completed":
38
+ status_icon = Text("✓ PASS", style="bold green")
39
+ elif status == "failed" or status == "error":
40
+ status_icon = Text("✗ FAIL", style="bold red")
41
+ elif status == "skipped":
42
+ status_icon = Text("⊘ SKIP", style="bold yellow")
43
+ else:
44
+ status_icon = Text(f"? {status.upper()}", style="bold yellow")
45
+
46
+ title = Text()
47
+ title.append(" ")
48
+ title.append(command, style="bold white")
49
+ title.append(" ")
50
+ title.append_text(status_icon)
51
+
52
+ meta_parts = []
53
+ if exit_code is not None:
54
+ meta_parts.append(f"exit={exit_code}")
55
+ if duration_s is not None:
56
+ meta_parts.append(f"time={duration_s}s")
57
+
58
+ footer = Text()
59
+ if meta_parts:
60
+ footer.append(" | ".join(meta_parts), style="dim")
61
+
62
+ content = Text()
63
+ if output:
64
+ output_str = output.rstrip()
65
+ if len(output_str) > 2000:
66
+ output_str = output_str[:2000] + "\n... (truncated)"
67
+ content.append(output_str)
68
+ else:
69
+ content.append("(no output)", style="dim italic")
70
+
71
+ border_style = "green" if "pass" in status or "success" in status or "completed" in status else "red" if "fail" in status or "error" in status else "yellow"
72
+
73
+ panel = Panel(
74
+ content,
75
+ title=title,
76
+ subtitle=footer if meta_parts else None,
77
+ border_style=border_style,
78
+ box=box.ROUNDED,
79
+ padding=(0, 1),
80
+ )
81
+ console.print(panel)
82
+
83
+
84
+ def print_error(message: str) -> None:
85
+ text = Text()
86
+ text.append("✗ Error: ", style="bold red")
87
+ text.append(message, style="red")
88
+ console.print(text)
89
+
90
+
91
+ def print_success(message: str) -> None:
92
+ text = Text()
93
+ text.append("✓ ", style="bold green")
94
+ text.append(message, style="green")
95
+ console.print(text)
96
+
97
+
98
+ def print_warning(message: str) -> None:
99
+ text = Text()
100
+ text.append("⚠ ", style="bold yellow")
101
+ text.append(message, style="yellow")
102
+ console.print(text)
103
+
104
+
105
+ def print_info(message: str) -> None:
106
+ text = Text()
107
+ text.append("ℹ ", style="bold blue")
108
+ text.append(message, style="blue")
109
+ console.print(text)
110
+
111
+
112
+ def print_status(status: dict) -> None:
113
+ table = Table(title="Connection Status", box=box.SIMPLE_HEAVY, show_header=False)
114
+ table.add_column("Key", style="bold cyan", width=16)
115
+ table.add_column("Value")
116
+
117
+ connected = status.get("connected", False)
118
+ api_url = status.get("api_url", "N/A")
119
+ token = status.get("token", None)
120
+ token_display = f"{token[:8]}...{token[-4:]}" if token and len(token) > 12 else (token if token else "Not set")
121
+ authenticated = status.get("authenticated", False)
122
+ last_run = status.get("last_run_id", "None")
123
+
124
+ table.add_row("Connected", Text("Yes ✓" if connected else "No ✗", style="green" if connected else "red"))
125
+ table.add_row("API URL", api_url)
126
+ table.add_row("Token", token_display)
127
+ table.add_row("Authenticated", Text("Yes" if authenticated else "No", style="green" if authenticated else "red"))
128
+ table.add_row("Last Run ID", str(last_run) if last_run else "None")
129
+
130
+ console.print(table)
131
+
132
+
133
+ def create_results_table(results: list) -> Table:
134
+ table = Table(title="Run Results", box=box.ROUNDED, show_lines=True)
135
+ table.add_column("#", style="dim", width=4)
136
+ table.add_column("Command", style="bold", ratio=3)
137
+ table.add_column("Status", width=10, justify="center")
138
+ table.add_column("Exit Code", width=10, justify="center")
139
+ table.add_column("Duration", width=10, justify="center")
140
+
141
+ for i, result in enumerate(results, 1):
142
+ command = result.get("command", "unknown")
143
+ status = result.get("status", "unknown")
144
+ exit_code = result.get("exit_code", "-")
145
+ duration_ms = result.get("duration_ms", result.get("duration", "-"))
146
+ duration = round(duration_ms / 1000, 2) if isinstance(duration_ms, (int, float)) and duration_ms > 100 else duration_ms
147
+
148
+ if status in ("passed", "success", "completed"):
149
+ status_display = Text("✓ PASS", style="bold green")
150
+ elif status in ("failed", "error"):
151
+ status_display = Text("✗ FAIL", style="bold red")
152
+ elif status == "skipped":
153
+ status_display = Text("⊘ SKIP", style="bold yellow")
154
+ else:
155
+ status_display = Text(f"? {status}", style="bold yellow")
156
+
157
+ if exit_code is not None:
158
+ exit_display = Text(str(exit_code), style="green" if exit_code == 0 else "red")
159
+ else:
160
+ exit_display = Text("-", style="dim")
161
+
162
+ duration_display = Text(f"{duration}s" if duration != "-" and duration is not None else "-", style="dim")
163
+
164
+ table.add_row(str(i), command, status_display, exit_display, duration_display)
165
+
166
+ return table
167
+
168
+
169
+ def print_run_summary(results: list, run_id: str = None) -> None:
170
+ total = len(results)
171
+ passed = sum(1 for r in results if r.get("status") in ("passed", "success", "completed"))
172
+ failed = sum(1 for r in results if r.get("status") in ("failed", "error"))
173
+ skipped = sum(1 for r in results if r.get("status") == "skipped")
174
+
175
+ console.print()
176
+ summary = Text()
177
+ summary.append("Summary: ", style="bold")
178
+ summary.append(f"{total} total, ", style="white")
179
+ summary.append(f"{passed} passed", style="green")
180
+ if failed > 0:
181
+ summary.append(f", {failed} failed", style="red")
182
+ if skipped > 0:
183
+ summary.append(f", {skipped} skipped", style="yellow")
184
+ if run_id:
185
+ summary.append(f" [run: {run_id}]", style="dim")
186
+
187
+ console.print(summary)
nexinal/runner.py ADDED
@@ -0,0 +1,135 @@
1
+ """Command execution engine for nexinal CLI."""
2
+
3
+ import time
4
+ from typing import List, Union, Optional
5
+ from dataclasses import dataclass, field
6
+
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from nexinal.api import api_post, APIError, AuthError, ConnectionError_
11
+ from nexinal.config import set_last_run_id, get_token
12
+ from nexinal.display import print_result, print_error, print_run_summary, console
13
+ from nexinal.yaml_parser import CommandEntry
14
+
15
+
16
+ @dataclass
17
+ class RunResult:
18
+ run_id: Optional[str] = None
19
+ results: List[dict] = field(default_factory=list)
20
+ success: bool = True
21
+ error: Optional[str] = None
22
+
23
+
24
+ def run_commands(
25
+ commands: List[Union[str, CommandEntry]],
26
+ stop_on_fail: bool = False,
27
+ ) -> RunResult:
28
+ if not get_token():
29
+ print_error("Not authenticated. Run: nexinal auth <token>")
30
+ return RunResult(success=False, error="Not authenticated")
31
+
32
+ command_strings: List[str] = []
33
+ entry_map: dict = {}
34
+ for i, cmd in enumerate(commands):
35
+ if isinstance(cmd, CommandEntry):
36
+ command_strings.append(cmd.command)
37
+ entry_map[i] = cmd
38
+ else:
39
+ command_strings.append(cmd)
40
+
41
+ total = len(command_strings)
42
+ result = RunResult()
43
+
44
+ try:
45
+ with Progress(
46
+ SpinnerColumn(),
47
+ TextColumn("[progress.description]{task.description}"),
48
+ console=console,
49
+ transient=True,
50
+ ) as progress:
51
+ task = progress.add_task(f"Running {total} command(s)...", total=total)
52
+
53
+ for i, cmd_str in enumerate(command_strings):
54
+ entry = entry_map.get(i)
55
+ label = entry.name if entry and entry.name else cmd_str
56
+ if len(label) > 60:
57
+ label = label[:57] + "..."
58
+ progress.update(task, description=f"[{i+1}/{total}] {label}")
59
+
60
+ try:
61
+ start = time.time()
62
+ response = api_post("/run", {"commands": [cmd_str]})
63
+ data = response.get("data", response)
64
+ duration = round(time.time() - start, 3)
65
+
66
+ run_id = data.get("id")
67
+ if run_id:
68
+ result.run_id = run_id
69
+ set_last_run_id(run_id)
70
+
71
+ cmd_results = data.get("results", [])
72
+ if cmd_results:
73
+ for cr in cmd_results:
74
+ cr.setdefault("command", cmd_str)
75
+ result.results.append(cr)
76
+
77
+ first = cmd_results[0]
78
+ status = first.get("status", "unknown")
79
+ if status in ("failed", "error") and stop_on_fail:
80
+ print_result(first)
81
+ result.success = False
82
+ result.error = f"Command failed: {cmd_str}"
83
+ console.print(f"[bold yellow]Stopped: stop_on_fail is enabled[/]")
84
+ break
85
+ print_result(first)
86
+ else:
87
+ entry_result = {
88
+ "command": cmd_str,
89
+ "status": data.get("status", "completed"),
90
+ "stdout": data.get("stdout", data.get("output", data.get("detail", ""))),
91
+ "exit_code": data.get("exit_code", 0),
92
+ "duration_ms": data.get("total_duration_ms", duration * 1000),
93
+ }
94
+ result.results.append(entry_result)
95
+ print_result(entry_result)
96
+
97
+ except AuthError as e:
98
+ print_error(str(e))
99
+ result.success = False
100
+ result.error = str(e)
101
+ break
102
+ except ConnectionError_ as e:
103
+ print_error(str(e))
104
+ result.success = False
105
+ result.error = str(e)
106
+ break
107
+ except APIError as e:
108
+ entry_result = {
109
+ "command": cmd_str,
110
+ "status": "error",
111
+ "output": str(e),
112
+ "exit_code": None,
113
+ "duration": None,
114
+ }
115
+ result.results.append(entry_result)
116
+ print_result(entry_result)
117
+ if stop_on_fail:
118
+ result.success = False
119
+ result.error = str(e)
120
+ break
121
+
122
+ progress.advance(task)
123
+
124
+ except KeyboardInterrupt:
125
+ console.print("\n[bold yellow]Interrupted by user[/]")
126
+ result.success = False
127
+ result.error = "Interrupted"
128
+ return result
129
+
130
+ print_run_summary(result.results, result.run_id)
131
+ return result
132
+
133
+
134
+ def run_single_command(command: str, stop_on_fail: bool = False) -> RunResult:
135
+ return run_commands([command], stop_on_fail=stop_on_fail)
nexinal/yaml_parser.py ADDED
@@ -0,0 +1,79 @@
1
+ """YAML file parser for nexinal CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Union, Optional
5
+ from dataclasses import dataclass
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class CommandEntry:
12
+ name: Optional[str]
13
+ command: str
14
+ timeout: Optional[int] = None
15
+ stop_on_fail: bool = False
16
+
17
+
18
+ class YAMLError(Exception):
19
+ pass
20
+
21
+
22
+ def parse_yaml_file(path: str) -> List[Union[str, CommandEntry]]:
23
+ file_path = Path(path)
24
+ if not file_path.exists():
25
+ raise YAMLError(f"File not found: {path}")
26
+ if not file_path.suffix in (".yaml", ".yml"):
27
+ raise YAMLError(f"Not a YAML file: {path}")
28
+
29
+ try:
30
+ with open(file_path, "r") as f:
31
+ data = yaml.safe_load(f)
32
+ except yaml.YAMLError as e:
33
+ raise YAMLError(f"Invalid YAML syntax in {path}: {e}")
34
+ except IOError as e:
35
+ raise YAMLError(f"Cannot read file {path}: {e}")
36
+
37
+ if data is None:
38
+ raise YAMLError(f"Empty YAML file: {path}")
39
+ if not isinstance(data, dict):
40
+ raise YAMLError(f"YAML must be a mapping with a 'commands' key: {path}")
41
+
42
+ commands_raw = data.get("commands")
43
+ if commands_raw is None:
44
+ raise YAMLError(f"No 'commands' key found in {path}")
45
+ if not isinstance(commands_raw, list):
46
+ raise YAMLError(f"'commands' must be a list: {path}")
47
+ if len(commands_raw) == 0:
48
+ raise YAMLError(f"'commands' list is empty in {path}")
49
+
50
+ parsed: List[Union[str, CommandEntry]] = []
51
+ for i, entry in enumerate(commands_raw):
52
+ if isinstance(entry, str):
53
+ if not entry.strip():
54
+ raise YAMLError(f"Empty command at index {i}")
55
+ parsed.append(entry.strip())
56
+ elif isinstance(entry, dict):
57
+ cmd = entry.get("command")
58
+ if not cmd or not isinstance(cmd, str) or not cmd.strip():
59
+ raise YAMLError(f"Command at index {i} must have a non-empty 'command' field")
60
+ timeout = entry.get("timeout")
61
+ if timeout is not None:
62
+ try:
63
+ timeout = int(timeout)
64
+ if timeout <= 0:
65
+ raise ValueError
66
+ except (TypeError, ValueError):
67
+ raise YAMLError(f"Invalid timeout at index {i}: must be a positive integer")
68
+ name = entry.get("name")
69
+ stop_on_fail = bool(entry.get("stop_on_fail", False))
70
+ parsed.append(CommandEntry(
71
+ name=name.strip() if isinstance(name, str) else None,
72
+ command=cmd.strip(),
73
+ timeout=timeout,
74
+ stop_on_fail=stop_on_fail,
75
+ ))
76
+ else:
77
+ raise YAMLError(f"Invalid command entry at index {i}: must be a string or mapping")
78
+
79
+ return parsed
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.1
2
+ Name: nexinal
3
+ Version: 1.0.2
4
+ Summary: CLI client for Nexuss Bash remote execution API
5
+ Home-page: https://github.com/nexuss0781/Nexuss-Bash
6
+ Author: Nexuss
7
+ Author-email: Nexuss <nexuss@proton.me>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/nexuss0781/Nexuss-Bash
10
+ Project-URL: Bug Reports, https://github.com/nexuss0781/Nexuss-Bash/issues
11
+ Project-URL: Source, https://github.com/nexuss0781/Nexuss-Bash
12
+ Keywords: cli,bash,remote,execution,api,nexuss,nexinal
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Topic :: Software Development :: Build Tools
26
+ Classifier: Topic :: Utilities
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ Requires-Dist: click >=8.0
30
+ Requires-Dist: pyyaml >=6.0
31
+ Requires-Dist: requests >=2.28
32
+ Requires-Dist: rich >=13.0
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest-cov >=4.0 ; extra == 'dev'
35
+ Requires-Dist: pytest >=7.0 ; extra == 'dev'
36
+
37
+ # nexinal
38
+
39
+ CLI client for the **Nexuss Bash** remote execution API. Run shell commands on a remote server directly from your terminal.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install nexinal
45
+ ```
46
+
47
+ Or from source:
48
+
49
+ ```bash
50
+ git clone https://github.com/nexuss0781/Nexuss-Bash
51
+ cd Nexuss-Bash/cli
52
+ pip install -e .
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```bash
58
+ # Authenticate
59
+ nexinal auth YOUR_API_TOKEN
60
+
61
+ # Run commands
62
+ nexinal run "echo hello" "whoami" "ls /workspace"
63
+
64
+ # Execute a YAML file
65
+ nexinal execute runbook.yaml
66
+
67
+ # Check status
68
+ nexinal status
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ | Command | Description |
74
+ |---------|-------------|
75
+ | `nexinal auth <token>` | Save authentication token |
76
+ | `nexinal run <commands...>` | Run shell commands sequentially |
77
+ | `nexinal execute <yaml_file>` | Execute commands from a YAML file |
78
+ | `nexinal status` | Show connection and token info |
79
+ | `nexinal history` | List past runs |
80
+ | `nexinal health` | Quick health check |
81
+ | `nexinal sessions` | List active sessions |
82
+ | `nexinal packages list` | List installed packages |
83
+ | `nexinal packages install <name>` | Install a package |
84
+ | `nexinal config` | Show or set API URL |
85
+ | `nexinal logout` | Remove saved token |
86
+
87
+ ## YAML File Format
88
+
89
+ ### Simple format
90
+
91
+ ```yaml
92
+ commands:
93
+ - "echo hello"
94
+ - "whoami"
95
+ - "ls -la /workspace"
96
+ ```
97
+
98
+ ### Detailed format
99
+
100
+ ```yaml
101
+ commands:
102
+ - name: "Print hello"
103
+ command: "echo hello"
104
+ timeout: 30
105
+ stop_on_fail: true
106
+ - name: "List files"
107
+ command: "ls -la"
108
+ timeout: 15
109
+ ```
110
+
111
+ ## Configuration
112
+
113
+ Config is stored at `~/.nexinal/config.json`.
114
+
115
+ ```bash
116
+ # Set custom API URL
117
+ nexinal config set https://my-server.example.com
118
+
119
+ # Show current config
120
+ nexinal config
121
+ ```
122
+
123
+ ## Environment
124
+
125
+ - Python 3.8+
126
+ - Works on Linux, macOS, Windows
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,13 @@
1
+ nexinal/__init__.py,sha256=hb7TtddQ4yn0DRHgMj5Rur3EETDfKWm3B_fc_tckq8o,113
2
+ nexinal/__main__.py,sha256=tjuzXCncKb_ViYlrFYaVUAE496_xsqsCptwCTZon77k,129
3
+ nexinal/api.py,sha256=Gso9kL-KKfagpxR876jTi7PNJYqSEkg-3_ReSlyN5Ds,5162
4
+ nexinal/cli.py,sha256=OUhRTFtjHdwx89lqt48WZZGHQpoXg20ujMqidt2RETs,10699
5
+ nexinal/config.py,sha256=C3n9ienyHtjhCqNscWHSry_9jfn8UQH4g-w46-Spsh0,1706
6
+ nexinal/display.py,sha256=9ZycThYyPE6abH8z96beJWDqTIh8cvBV7jebE55MVGE,6621
7
+ nexinal/runner.py,sha256=jpyAm9iUAxnJonrR6HfHknxPFVj1d54VKGsRfZe6wlE,5100
8
+ nexinal/yaml_parser.py,sha256=LCDmOE-ZNZHX6oGVzLDd_TVgOtCgKGY2S6DVI3h-xGs,2742
9
+ nexinal-1.0.2.dist-info/METADATA,sha256=D0URVfy7O-9qRmUZHRJAC6iGwQ1X71kfBBkXR6BbHlI,3198
10
+ nexinal-1.0.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
11
+ nexinal-1.0.2.dist-info/entry_points.txt,sha256=mJJe9XMo0FAubSMGT3uLSU3s9Uyt-LAvtv5B8OEseD4,45
12
+ nexinal-1.0.2.dist-info/top_level.txt,sha256=drOJadouOgKeVwe7eCjGz-bTvoLeeD4qN3QesjvOF-E,8
13
+ nexinal-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nexinal = nexinal.cli:main
@@ -0,0 +1 @@
1
+ nexinal