parad 1.0.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.
parad/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """parad - CLI client for Nexuss Bash remote execution API."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "parad"
parad/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running parad as a module: python -m parad."""
2
+
3
+ from parad.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
parad/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 parad.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: parad 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: parad 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: parad 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: parad 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
parad/cli.py ADDED
@@ -0,0 +1,359 @@
1
+ """Main Click CLI for parad."""
2
+
3
+ import sys
4
+ from typing import List
5
+
6
+ import click
7
+
8
+ from parad import __version__
9
+ from parad.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 parad.api import api_get, api_post, APIError, AuthError, ConnectionError_, test_connection
14
+ from parad.runner import run_commands
15
+ from parad.yaml_parser import parse_yaml_file, YAMLError, CommandEntry
16
+ from parad.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="parad")
25
+ def main():
26
+ """parad - 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 ~/.parad/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: parad 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: parad 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: parad 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: parad 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: parad 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()
parad/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Configuration manager for parad CLI."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ CONFIG_DIR = Path.home() / ".parad"
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()
parad/display.py ADDED
@@ -0,0 +1,185 @@
1
+ """Rich display utilities for parad 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("output", "")
33
+ exit_code = result.get("exit_code", None)
34
+ duration = result.get("duration", None)
35
+
36
+ if status == "passed" or status == "success" or status == "completed":
37
+ status_icon = Text("✓ PASS", style="bold green")
38
+ elif status == "failed" or status == "error":
39
+ status_icon = Text("✗ FAIL", style="bold red")
40
+ elif status == "skipped":
41
+ status_icon = Text("⊘ SKIP", style="bold yellow")
42
+ else:
43
+ status_icon = Text(f"? {status.upper()}", style="bold yellow")
44
+
45
+ title = Text()
46
+ title.append(" ")
47
+ title.append(command, style="bold white")
48
+ title.append(" ")
49
+ title.append_obj(status_icon)
50
+
51
+ meta_parts = []
52
+ if exit_code is not None:
53
+ meta_parts.append(f"exit={exit_code}")
54
+ if duration is not None:
55
+ meta_parts.append(f"time={duration}s")
56
+
57
+ footer = Text()
58
+ if meta_parts:
59
+ footer.append(" | ".join(meta_parts), style="dim")
60
+
61
+ content = Text()
62
+ if output:
63
+ output_str = output.rstrip()
64
+ if len(output_str) > 2000:
65
+ output_str = output_str[:2000] + "\n... (truncated)"
66
+ content.append(output_str)
67
+ else:
68
+ content.append("(no output)", style="dim italic")
69
+
70
+ 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"
71
+
72
+ panel = Panel(
73
+ content,
74
+ title=title,
75
+ subtitle=footer if meta_parts else None,
76
+ border_style=border_style,
77
+ box=box.ROUNDED,
78
+ padding=(0, 1),
79
+ )
80
+ console.print(panel)
81
+
82
+
83
+ def print_error(message: str) -> None:
84
+ text = Text()
85
+ text.append("✗ Error: ", style="bold red")
86
+ text.append(message, style="red")
87
+ console.print(text)
88
+
89
+
90
+ def print_success(message: str) -> None:
91
+ text = Text()
92
+ text.append("✓ ", style="bold green")
93
+ text.append(message, style="green")
94
+ console.print(text)
95
+
96
+
97
+ def print_warning(message: str) -> None:
98
+ text = Text()
99
+ text.append("⚠ ", style="bold yellow")
100
+ text.append(message, style="yellow")
101
+ console.print(text)
102
+
103
+
104
+ def print_info(message: str) -> None:
105
+ text = Text()
106
+ text.append("ℹ ", style="bold blue")
107
+ text.append(message, style="blue")
108
+ console.print(text)
109
+
110
+
111
+ def print_status(status: dict) -> None:
112
+ table = Table(title="Connection Status", box=box.SIMPLE_HEAVY, show_header=False)
113
+ table.add_column("Key", style="bold cyan", width=16)
114
+ table.add_column("Value")
115
+
116
+ connected = status.get("connected", False)
117
+ api_url = status.get("api_url", "N/A")
118
+ token = status.get("token", None)
119
+ token_display = f"{token[:8]}...{token[-4:]}" if token and len(token) > 12 else (token if token else "Not set")
120
+ authenticated = status.get("authenticated", False)
121
+ last_run = status.get("last_run_id", "None")
122
+
123
+ table.add_row("Connected", Text("Yes ✓" if connected else "No ✗", style="green" if connected else "red"))
124
+ table.add_row("API URL", api_url)
125
+ table.add_row("Token", token_display)
126
+ table.add_row("Authenticated", Text("Yes" if authenticated else "No", style="green" if authenticated else "red"))
127
+ table.add_row("Last Run ID", str(last_run) if last_run else "None")
128
+
129
+ console.print(table)
130
+
131
+
132
+ def create_results_table(results: list) -> Table:
133
+ table = Table(title="Run Results", box=box.ROUNDED, show_lines=True)
134
+ table.add_column("#", style="dim", width=4)
135
+ table.add_column("Command", style="bold", ratio=3)
136
+ table.add_column("Status", width=10, justify="center")
137
+ table.add_column("Exit Code", width=10, justify="center")
138
+ table.add_column("Duration", width=10, justify="center")
139
+
140
+ for i, result in enumerate(results, 1):
141
+ command = result.get("command", "unknown")
142
+ status = result.get("status", "unknown")
143
+ exit_code = result.get("exit_code", "-")
144
+ duration = result.get("duration", "-")
145
+
146
+ if status in ("passed", "success", "completed"):
147
+ status_display = Text("✓ PASS", style="bold green")
148
+ elif status in ("failed", "error"):
149
+ status_display = Text("✗ FAIL", style="bold red")
150
+ elif status == "skipped":
151
+ status_display = Text("⊘ SKIP", style="bold yellow")
152
+ else:
153
+ status_display = Text(f"? {status}", style="bold yellow")
154
+
155
+ if exit_code is not None:
156
+ exit_display = Text(str(exit_code), style="green" if exit_code == 0 else "red")
157
+ else:
158
+ exit_display = Text("-", style="dim")
159
+
160
+ duration_display = Text(f"{duration}s" if duration != "-" and duration is not None else "-", style="dim")
161
+
162
+ table.add_row(str(i), command, status_display, exit_display, duration_display)
163
+
164
+ return table
165
+
166
+
167
+ def print_run_summary(results: list, run_id: str = None) -> None:
168
+ total = len(results)
169
+ passed = sum(1 for r in results if r.get("status") in ("passed", "success", "completed"))
170
+ failed = sum(1 for r in results if r.get("status") in ("failed", "error"))
171
+ skipped = sum(1 for r in results if r.get("status") == "skipped")
172
+
173
+ console.print()
174
+ summary = Text()
175
+ summary.append("Summary: ", style="bold")
176
+ summary.append(f"{total} total, ", style="white")
177
+ summary.append(f"{passed} passed", style="green")
178
+ if failed > 0:
179
+ summary.append(f", {failed} failed", style="red")
180
+ if skipped > 0:
181
+ summary.append(f", {skipped} skipped", style="yellow")
182
+ if run_id:
183
+ summary.append(f" [run: {run_id}]", style="dim")
184
+
185
+ console.print(summary)
parad/runner.py ADDED
@@ -0,0 +1,135 @@
1
+ """Command execution engine for parad 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 parad.api import api_post, APIError, AuthError, ConnectionError_
11
+ from parad.config import set_last_run_id, get_token
12
+ from parad.display import print_result, print_error, print_run_summary, console
13
+ from parad.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: parad 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
+ duration = round(time.time() - start, 3)
64
+
65
+ run_id = response.get("run_id") or response.get("id")
66
+ if run_id:
67
+ result.run_id = run_id
68
+ set_last_run_id(run_id)
69
+
70
+ cmd_results = response.get("results", [])
71
+ if cmd_results:
72
+ for cr in cmd_results:
73
+ cr.setdefault("command", cmd_str)
74
+ cr.setdefault("duration", duration)
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": response.get("status", "completed"),
90
+ "output": response.get("output", response.get("detail", "")),
91
+ "exit_code": response.get("exit_code", 0),
92
+ "duration": duration,
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)
parad/yaml_parser.py ADDED
@@ -0,0 +1,79 @@
1
+ """YAML file parser for parad 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: parad
3
+ Version: 1.0.0
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,parad
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
+ # parad
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 parad
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
+ parad auth YOUR_API_TOKEN
60
+
61
+ # Run commands
62
+ parad run "echo hello" "whoami" "ls /workspace"
63
+
64
+ # Execute a YAML file
65
+ parad execute runbook.yaml
66
+
67
+ # Check status
68
+ parad status
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ | Command | Description |
74
+ |---------|-------------|
75
+ | `parad auth <token>` | Save authentication token |
76
+ | `parad run <commands...>` | Run shell commands sequentially |
77
+ | `parad execute <yaml_file>` | Execute commands from a YAML file |
78
+ | `parad status` | Show connection and token info |
79
+ | `parad history` | List past runs |
80
+ | `parad health` | Quick health check |
81
+ | `parad sessions` | List active sessions |
82
+ | `parad packages list` | List installed packages |
83
+ | `parad packages install <name>` | Install a package |
84
+ | `parad config` | Show or set API URL |
85
+ | `parad 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 `~/.parad/config.json`.
114
+
115
+ ```bash
116
+ # Set custom API URL
117
+ parad config set https://my-server.example.com
118
+
119
+ # Show current config
120
+ parad 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
+ parad/__init__.py,sha256=69ojEAOc56n1vYg0UGd2sN9guaolwARRhb6e1YnnkZg,109
2
+ parad/__main__.py,sha256=06VyC_ty-L-3cagB6FsCd41ESu1jmnb1Z0xtU8tvWiI,123
3
+ parad/api.py,sha256=AeZAru4kumrLtV-YZiKmlNLrRlf3zx-H4ArrP5x3H44,5152
4
+ parad/cli.py,sha256=vfb9jmhE17X8PS-qEq-7TaHjZiXKf7pho1I2pXn47LE,10669
5
+ parad/config.py,sha256=HrDVWEFkXHjT8qVR96Dp_XRz8rvHj5-Ozpfjas_CKR8,1702
6
+ parad/display.py,sha256=0Byep3NKIwGsDa9yRjnjLxD_JQu-zRvUxTYuq28wLOE,6278
7
+ parad/runner.py,sha256=NASr-_bYLoZg0bgUMwJiPNuUsv40gpgFKuKe4hsSR0o,5083
8
+ parad/yaml_parser.py,sha256=JAbg72hh8tAJJk6PgudCABP0-yLFXLS9wc0t68tJWQY,2740
9
+ parad-1.0.0.dist-info/METADATA,sha256=2ke5yfMX05e-JQ37yNAnhXsKzzevz7WGbJ_qtZTkM-M,3154
10
+ parad-1.0.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
11
+ parad-1.0.0.dist-info/entry_points.txt,sha256=iZEbhLutmflksT4OYkG8VORWMX8xuIML81FXfsoR8n0,41
12
+ parad-1.0.0.dist-info/top_level.txt,sha256=J8oold_nInyawtNxhKZIaDjbDgxbyJA2ODb2j0IJ6NE,6
13
+ parad-1.0.0.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
+ parad = parad.cli:main
@@ -0,0 +1 @@
1
+ parad