astromesh-cli 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astromesh_cli-0.1.1/.gitignore +39 -0
- astromesh_cli-0.1.1/PKG-INFO +23 -0
- astromesh_cli-0.1.1/astromesh_cli/__init__.py +3 -0
- astromesh_cli-0.1.1/astromesh_cli/client.py +41 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/__init__.py +1 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/agents.py +41 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/ask.py +111 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/dev.py +43 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/doctor.py +74 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/mesh.py +105 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/metrics.py +43 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/new.py +168 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/peers.py +38 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/providers.py +38 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/run.py +117 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/services.py +41 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/status.py +22 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/tools.py +67 -0
- astromesh_cli-0.1.1/astromesh_cli/commands/traces.py +57 -0
- astromesh_cli-0.1.1/astromesh_cli/main.py +75 -0
- astromesh_cli-0.1.1/astromesh_cli/output.py +153 -0
- astromesh_cli-0.1.1/astromesh_cli/templates/agent.yaml.j2 +29 -0
- astromesh_cli-0.1.1/astromesh_cli/templates/tool.py.j2 +11 -0
- astromesh_cli-0.1.1/astromesh_cli/templates/workflow.yaml.j2 +7 -0
- astromesh_cli-0.1.1/pyproject.toml +38 -0
- astromesh_cli-0.1.1/tests/conftest.py +1 -0
- astromesh_cli-0.1.1/tests/test_client.py +87 -0
- astromesh_cli-0.1.1/tests/test_main.py +38 -0
- astromesh_cli-0.1.1/tests/test_output.py +117 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
*.egg
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
env/
|
|
12
|
+
.env
|
|
13
|
+
*.db
|
|
14
|
+
*.sqlite3
|
|
15
|
+
models/*.pt
|
|
16
|
+
models/*.onnx
|
|
17
|
+
models/*.bin
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
htmlcov/
|
|
22
|
+
.coverage
|
|
23
|
+
*.log
|
|
24
|
+
.uv/
|
|
25
|
+
uv.lock
|
|
26
|
+
target/
|
|
27
|
+
native/target/
|
|
28
|
+
*.so
|
|
29
|
+
*.pyd
|
|
30
|
+
*.dylib
|
|
31
|
+
deploy/helm/astromesh/charts/*.tgz
|
|
32
|
+
.worktrees/
|
|
33
|
+
staging/
|
|
34
|
+
docs-site/node_modules/
|
|
35
|
+
docs-site/dist/
|
|
36
|
+
docs-site/.astro/
|
|
37
|
+
|
|
38
|
+
# Astromesh Orbit working directory
|
|
39
|
+
.orbit/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astromesh-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI tool for managing Astromesh nodes and clusters
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: astromesh>=0.18.0
|
|
8
|
+
Requires-Dist: jinja2>=3.1.0
|
|
9
|
+
Requires-Dist: rich>=13.0.0
|
|
10
|
+
Requires-Dist: typer>=0.12.0
|
|
11
|
+
Provides-Extra: all
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'all'
|
|
13
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'all'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'all'
|
|
15
|
+
Requires-Dist: respx>=0.21.0; extra == 'all'
|
|
16
|
+
Requires-Dist: watchfiles>=0.21.0; extra == 'all'
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: watchfiles>=0.21.0; extra == 'dev'
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
23
|
+
Requires-Dist: respx>=0.21.0; extra == 'test'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""HTTP client for communicating with astromeshd."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
DEFAULT_URL = "http://localhost:8000"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_base_url() -> str:
|
|
11
|
+
return os.environ.get("ASTROMESH_DAEMON_URL", DEFAULT_URL)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def api_get(path: str) -> dict:
|
|
15
|
+
url = f"{get_base_url()}{path}"
|
|
16
|
+
resp = httpx.get(url, timeout=5.0)
|
|
17
|
+
resp.raise_for_status()
|
|
18
|
+
return resp.json()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def api_post(path: str, json: dict | None = None) -> dict:
|
|
22
|
+
url = f"{get_base_url()}{path}"
|
|
23
|
+
resp = httpx.post(url, json=json, timeout=5.0)
|
|
24
|
+
resp.raise_for_status()
|
|
25
|
+
return resp.json()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def api_post_with_timeout(path: str, json: dict | None = None, timeout: float = 30.0) -> dict:
|
|
29
|
+
"""POST with configurable timeout for long-running operations."""
|
|
30
|
+
url = f"{get_base_url()}{path}"
|
|
31
|
+
resp = httpx.post(url, json=json, timeout=timeout)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
return resp.json()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def api_get_params(path: str, params: dict | None = None, timeout: float = 5.0) -> dict:
|
|
37
|
+
"""GET with query parameters."""
|
|
38
|
+
url = f"{get_base_url()}{path}"
|
|
39
|
+
resp = httpx.get(url, params=params, timeout=timeout)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
return resp.json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""astromeshctl agents commands."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from astromesh_cli.client import api_get
|
|
7
|
+
from astromesh_cli.output import console, print_error, print_json
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage agents.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_agents(json: bool = typer.Option(False, "--json", help="Output as JSON")):
|
|
14
|
+
"""List loaded agents."""
|
|
15
|
+
try:
|
|
16
|
+
data = api_get("/v1/agents")
|
|
17
|
+
if json:
|
|
18
|
+
print_json(data)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
agents = data.get("agents", [])
|
|
22
|
+
if not agents:
|
|
23
|
+
console.print("[dim]No agents loaded.[/dim]")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
table = Table(title="Loaded Agents")
|
|
27
|
+
table.add_column("Name", style="cyan")
|
|
28
|
+
table.add_column("Version", style="green")
|
|
29
|
+
table.add_column("Namespace", style="dim")
|
|
30
|
+
|
|
31
|
+
for agent in agents:
|
|
32
|
+
table.add_row(
|
|
33
|
+
agent.get("name", ""),
|
|
34
|
+
agent.get("version", ""),
|
|
35
|
+
agent.get("namespace", ""),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console.print(table)
|
|
39
|
+
except Exception:
|
|
40
|
+
print_error("Daemon not reachable.")
|
|
41
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""astromeshctl ask — Copilot CLI interface."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
from astromesh_cli.client import api_post_with_timeout
|
|
12
|
+
from astromesh_cli.output import console, print_error, print_json
|
|
13
|
+
|
|
14
|
+
COPILOT_AGENT = "astromesh-copilot"
|
|
15
|
+
MAX_CONTEXT_SIZE = 100 * 1024 # 100KB
|
|
16
|
+
ALLOWED_CONTEXT_PREFIXES = ("config", "docs")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_context_path(path: Path) -> bool:
|
|
20
|
+
"""Validate that a context file path is safe to read.
|
|
21
|
+
|
|
22
|
+
Must be under ./config/ or ./docs/, must exist, and must be < 100KB.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
resolved = path.resolve()
|
|
26
|
+
cwd = Path.cwd().resolve()
|
|
27
|
+
|
|
28
|
+
# Check that the path is under an allowed prefix
|
|
29
|
+
relative = resolved.relative_to(cwd)
|
|
30
|
+
parts = relative.parts
|
|
31
|
+
if not parts or parts[0] not in ALLOWED_CONTEXT_PREFIXES:
|
|
32
|
+
print_error(f"Context file must be under config/ or docs/. Got: {relative}")
|
|
33
|
+
return False
|
|
34
|
+
except (ValueError, OSError):
|
|
35
|
+
print_error(f"Context file path is not within the project directory: {path}")
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
if not path.exists():
|
|
39
|
+
print_error(f"Context file does not exist: {path}")
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
print_error(f"Context path is not a file: {path}")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if path.stat().st_size > MAX_CONTEXT_SIZE:
|
|
47
|
+
print_error(f"Context file too large (max 100KB): {path}")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ask_command(
|
|
54
|
+
query: str = typer.Argument(..., help="Question or request for the copilot"),
|
|
55
|
+
context: Optional[str] = typer.Option(
|
|
56
|
+
None, "--context", help="Path to a context file (must be under config/ or docs/)"
|
|
57
|
+
),
|
|
58
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Run in dry-run mode (no side effects)"),
|
|
59
|
+
session: Optional[str] = typer.Option(
|
|
60
|
+
None, "--session", help="Session ID for multi-turn conversation"
|
|
61
|
+
),
|
|
62
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Ask the Astromesh Copilot a question."""
|
|
65
|
+
full_query = query
|
|
66
|
+
context_content = None
|
|
67
|
+
|
|
68
|
+
if context:
|
|
69
|
+
context_path = Path(context)
|
|
70
|
+
if not _validate_context_path(context_path):
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
context_content = context_path.read_text()
|
|
73
|
+
full_query = f"{query}\n\n---\nContext from {context}:\n{context_content}"
|
|
74
|
+
|
|
75
|
+
session_id = session or str(uuid.uuid4())
|
|
76
|
+
|
|
77
|
+
metadata: dict = {}
|
|
78
|
+
if context_content:
|
|
79
|
+
metadata["context_file"] = context_content
|
|
80
|
+
if dry_run:
|
|
81
|
+
metadata["dry_run"] = True
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
data = api_post_with_timeout(
|
|
85
|
+
f"/v1/agents/{COPILOT_AGENT}/run",
|
|
86
|
+
json={
|
|
87
|
+
"query": full_query,
|
|
88
|
+
"session_id": session_id,
|
|
89
|
+
"metadata": metadata,
|
|
90
|
+
},
|
|
91
|
+
timeout=60.0,
|
|
92
|
+
)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print_error(f"Failed to reach copilot: {e}")
|
|
95
|
+
raise typer.Exit(code=1)
|
|
96
|
+
|
|
97
|
+
if json_output:
|
|
98
|
+
print_json(data)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
response_text = data.get("response", "")
|
|
102
|
+
trace_id = data.get("trace_id", "N/A")
|
|
103
|
+
|
|
104
|
+
console.print(
|
|
105
|
+
Panel(
|
|
106
|
+
Markdown(response_text),
|
|
107
|
+
title="[cyan]Astromesh Copilot[/cyan]",
|
|
108
|
+
subtitle=f"trace: {trace_id}",
|
|
109
|
+
border_style="cyan",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""astromeshctl dev — Hot-reload development server."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
from astromesh_cli.output import console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _launch_uvicorn(
|
|
10
|
+
host: str = "0.0.0.0",
|
|
11
|
+
port: int = 8000,
|
|
12
|
+
reload: bool = True,
|
|
13
|
+
reload_dirs: list[str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Launch uvicorn with the given configuration."""
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
uvicorn.run(
|
|
19
|
+
"astromesh.api.main:app",
|
|
20
|
+
host=host,
|
|
21
|
+
port=port,
|
|
22
|
+
reload=reload,
|
|
23
|
+
reload_dirs=reload_dirs or ["astromesh", "config"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dev_command(
|
|
28
|
+
host: str = typer.Option("0.0.0.0", "--host", help="Bind host"),
|
|
29
|
+
port: int = typer.Option(8000, "--port", help="Bind port"),
|
|
30
|
+
config: str = typer.Option("./config", "--config", help="Config directory"),
|
|
31
|
+
no_open: bool = typer.Option(False, "--no-open", help="Skip opening browser"),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Start the Astromesh dev server with hot-reload."""
|
|
34
|
+
banner = (
|
|
35
|
+
f"[bold cyan]Astromesh Dev Server[/bold cyan]\n\n"
|
|
36
|
+
f" Host: {host}\n"
|
|
37
|
+
f" Port: {port}\n"
|
|
38
|
+
f" Config: {config}\n"
|
|
39
|
+
f" Reload: enabled"
|
|
40
|
+
)
|
|
41
|
+
console.print(Panel(banner, title="astromesh dev", border_style="cyan"))
|
|
42
|
+
|
|
43
|
+
_launch_uvicorn(host=host, port=port, reload=True, reload_dirs=["astromesh", "config"])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""astromeshctl doctor command."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from astromesh_cli.client import api_get
|
|
10
|
+
from astromesh_cli.output import console, print_error, print_json
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
|
|
14
|
+
STATUS_ICONS = {
|
|
15
|
+
"ok": "[green]OK[/green]",
|
|
16
|
+
"degraded": "[yellow]DEGRADED[/yellow]",
|
|
17
|
+
"error": "[red]ERROR[/red]",
|
|
18
|
+
"unavailable": "[red]UNAVAILABLE[/red]",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_stale_astromesh_package() -> str | None:
|
|
23
|
+
"""Return a warning string if the old 'astromesh' deb package is still installed."""
|
|
24
|
+
if sys.platform != "linux":
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["dpkg", "-l", "astromesh"],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=5,
|
|
32
|
+
)
|
|
33
|
+
if result.returncode == 0 and "astromesh" in result.stdout:
|
|
34
|
+
# Ensure it's the old package, not the new astromesh-node package
|
|
35
|
+
if "astromesh-node" not in result.stdout:
|
|
36
|
+
return (
|
|
37
|
+
"Stale 'astromesh' package detected. "
|
|
38
|
+
"Upgrade to astromesh-node: sudo dpkg -i astromesh-node-*.deb"
|
|
39
|
+
)
|
|
40
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
41
|
+
pass # dpkg not available or timed out
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback(invoke_without_command=True)
|
|
46
|
+
def doctor(json: bool = typer.Option(False, "--json", help="Output as JSON")):
|
|
47
|
+
"""Run system health checks."""
|
|
48
|
+
migration_warning = _check_stale_astromesh_package()
|
|
49
|
+
if migration_warning:
|
|
50
|
+
console.print(f"[yellow]WARNING:[/yellow] {migration_warning}\n")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
data = api_get("/v1/system/doctor")
|
|
54
|
+
if json:
|
|
55
|
+
print_json(data)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
healthy = data["healthy"]
|
|
59
|
+
header = "[green]System Healthy[/green]" if healthy else "[red]System Unhealthy[/red]"
|
|
60
|
+
console.print(f"\n{header}\n")
|
|
61
|
+
|
|
62
|
+
table = Table()
|
|
63
|
+
table.add_column("Check", style="cyan")
|
|
64
|
+
table.add_column("Status")
|
|
65
|
+
table.add_column("Message", style="dim")
|
|
66
|
+
|
|
67
|
+
for name, check in data["checks"].items():
|
|
68
|
+
status_display = STATUS_ICONS.get(check["status"], check["status"])
|
|
69
|
+
table.add_row(name, status_display, check.get("message", ""))
|
|
70
|
+
|
|
71
|
+
console.print(table)
|
|
72
|
+
except Exception:
|
|
73
|
+
print_error("Daemon not reachable at configured URL.")
|
|
74
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""astromeshctl mesh commands."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from astromesh_cli.client import api_get, api_post
|
|
7
|
+
from astromesh_cli.output import console, print_error, print_json
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Mesh cluster management.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("status")
|
|
13
|
+
def mesh_status(json: bool = typer.Option(False, "--json", help="Output as JSON")):
|
|
14
|
+
"""Show mesh cluster summary."""
|
|
15
|
+
try:
|
|
16
|
+
data = api_get("/v1/mesh/state")
|
|
17
|
+
if json:
|
|
18
|
+
print_json(data)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
nodes = data.get("nodes", [])
|
|
22
|
+
leader_id = data.get("leader_id")
|
|
23
|
+
leader_name = ""
|
|
24
|
+
for n in nodes:
|
|
25
|
+
if n.get("node_id") == leader_id:
|
|
26
|
+
leader_name = n.get("name", leader_id)
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
console.print("[bold]Mesh Cluster[/bold]")
|
|
30
|
+
console.print(f" Nodes: {len(nodes)}")
|
|
31
|
+
console.print(f" Leader: {leader_name or 'none'}")
|
|
32
|
+
console.print(f" Version: {data.get('version', 0)}")
|
|
33
|
+
|
|
34
|
+
alive = sum(1 for n in nodes if n.get("status") == "alive")
|
|
35
|
+
suspect = sum(1 for n in nodes if n.get("status") == "suspect")
|
|
36
|
+
dead = sum(1 for n in nodes if n.get("status") == "dead")
|
|
37
|
+
console.print(f" Alive: {alive} Suspect: {suspect} Dead: {dead}")
|
|
38
|
+
except Exception:
|
|
39
|
+
print_error("Mesh not enabled or daemon not reachable.")
|
|
40
|
+
raise typer.Exit(code=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("nodes")
|
|
44
|
+
def mesh_nodes(json: bool = typer.Option(False, "--json", help="Output as JSON")):
|
|
45
|
+
"""List all nodes in the mesh."""
|
|
46
|
+
try:
|
|
47
|
+
data = api_get("/v1/mesh/state")
|
|
48
|
+
if json:
|
|
49
|
+
print_json(data)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
nodes = data.get("nodes", [])
|
|
53
|
+
leader_id = data.get("leader_id")
|
|
54
|
+
|
|
55
|
+
if not nodes:
|
|
56
|
+
console.print("[dim]No nodes in mesh.[/dim]")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
table = Table(title="Mesh Nodes")
|
|
60
|
+
table.add_column("Name", style="cyan")
|
|
61
|
+
table.add_column("URL", style="green")
|
|
62
|
+
table.add_column("Services", style="dim")
|
|
63
|
+
table.add_column("Agents", style="dim")
|
|
64
|
+
table.add_column("Load", style="yellow")
|
|
65
|
+
table.add_column("Status")
|
|
66
|
+
table.add_column("Leader")
|
|
67
|
+
|
|
68
|
+
for node in nodes:
|
|
69
|
+
services = ", ".join(node.get("services", []))
|
|
70
|
+
agents = ", ".join(node.get("agents", []))
|
|
71
|
+
load = node.get("load", {})
|
|
72
|
+
load_str = f"CPU:{load.get('cpu_percent', 0):.0f}% Req:{load.get('active_requests', 0)}"
|
|
73
|
+
status = node.get("status", "unknown")
|
|
74
|
+
status_display = {
|
|
75
|
+
"alive": "[green]alive[/green]",
|
|
76
|
+
"suspect": "[yellow]suspect[/yellow]",
|
|
77
|
+
"dead": "[red]dead[/red]",
|
|
78
|
+
}.get(status, status)
|
|
79
|
+
is_leader = "[bold green]YES[/bold green]" if node.get("node_id") == leader_id else ""
|
|
80
|
+
|
|
81
|
+
table.add_row(
|
|
82
|
+
node.get("name", ""),
|
|
83
|
+
node.get("url", ""),
|
|
84
|
+
services,
|
|
85
|
+
agents,
|
|
86
|
+
load_str,
|
|
87
|
+
status_display,
|
|
88
|
+
is_leader,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
except Exception:
|
|
93
|
+
print_error("Mesh not enabled or daemon not reachable.")
|
|
94
|
+
raise typer.Exit(code=0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("leave")
|
|
98
|
+
def mesh_leave():
|
|
99
|
+
"""Gracefully leave the mesh."""
|
|
100
|
+
try:
|
|
101
|
+
api_post("/v1/mesh/leave", json={"node_id": "self"})
|
|
102
|
+
console.print("[green]Left mesh successfully.[/green]")
|
|
103
|
+
except Exception:
|
|
104
|
+
print_error("Failed to leave mesh.")
|
|
105
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""astromeshctl metrics/cost — Aggregated metrics and cost summary."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from astromesh_cli.client import api_get
|
|
8
|
+
from astromesh_cli.output import print_cost_table, print_error, print_json, print_metrics_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def metrics_command(
|
|
12
|
+
agent: Optional[str] = typer.Option(None, "--agent", help="Filter by agent name"),
|
|
13
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Show aggregated runtime metrics."""
|
|
16
|
+
try:
|
|
17
|
+
data = api_get("/v1/metrics/")
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print_error(f"Failed to fetch metrics: {e}")
|
|
20
|
+
raise typer.Exit(code=1)
|
|
21
|
+
|
|
22
|
+
if json_output:
|
|
23
|
+
print_json(data)
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
print_metrics_table(data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cost_command(
|
|
30
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Show cost summary."""
|
|
33
|
+
try:
|
|
34
|
+
data = api_get("/v1/metrics/")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print_error(f"Failed to fetch metrics: {e}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
if json_output:
|
|
40
|
+
print_json(data)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
print_cost_table(data)
|