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.
Files changed (29) hide show
  1. astromesh_cli-0.1.1/.gitignore +39 -0
  2. astromesh_cli-0.1.1/PKG-INFO +23 -0
  3. astromesh_cli-0.1.1/astromesh_cli/__init__.py +3 -0
  4. astromesh_cli-0.1.1/astromesh_cli/client.py +41 -0
  5. astromesh_cli-0.1.1/astromesh_cli/commands/__init__.py +1 -0
  6. astromesh_cli-0.1.1/astromesh_cli/commands/agents.py +41 -0
  7. astromesh_cli-0.1.1/astromesh_cli/commands/ask.py +111 -0
  8. astromesh_cli-0.1.1/astromesh_cli/commands/dev.py +43 -0
  9. astromesh_cli-0.1.1/astromesh_cli/commands/doctor.py +74 -0
  10. astromesh_cli-0.1.1/astromesh_cli/commands/mesh.py +105 -0
  11. astromesh_cli-0.1.1/astromesh_cli/commands/metrics.py +43 -0
  12. astromesh_cli-0.1.1/astromesh_cli/commands/new.py +168 -0
  13. astromesh_cli-0.1.1/astromesh_cli/commands/peers.py +38 -0
  14. astromesh_cli-0.1.1/astromesh_cli/commands/providers.py +38 -0
  15. astromesh_cli-0.1.1/astromesh_cli/commands/run.py +117 -0
  16. astromesh_cli-0.1.1/astromesh_cli/commands/services.py +41 -0
  17. astromesh_cli-0.1.1/astromesh_cli/commands/status.py +22 -0
  18. astromesh_cli-0.1.1/astromesh_cli/commands/tools.py +67 -0
  19. astromesh_cli-0.1.1/astromesh_cli/commands/traces.py +57 -0
  20. astromesh_cli-0.1.1/astromesh_cli/main.py +75 -0
  21. astromesh_cli-0.1.1/astromesh_cli/output.py +153 -0
  22. astromesh_cli-0.1.1/astromesh_cli/templates/agent.yaml.j2 +29 -0
  23. astromesh_cli-0.1.1/astromesh_cli/templates/tool.py.j2 +11 -0
  24. astromesh_cli-0.1.1/astromesh_cli/templates/workflow.yaml.j2 +7 -0
  25. astromesh_cli-0.1.1/pyproject.toml +38 -0
  26. astromesh_cli-0.1.1/tests/conftest.py +1 -0
  27. astromesh_cli-0.1.1/tests/test_client.py +87 -0
  28. astromesh_cli-0.1.1/tests/test_main.py +38 -0
  29. 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,3 @@
1
+ """Astromesh CLI — manage Astromesh nodes and clusters."""
2
+
3
+ __version__ = "0.1.1"
@@ -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)