astromesh-cli 0.1.1__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.
@@ -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)
@@ -0,0 +1,168 @@
1
+ """astromeshctl new — Scaffold new agents, workflows, and tools."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from jinja2 import Environment, FileSystemLoader
8
+ from rich.panel import Panel
9
+
10
+ from astromesh_cli.output import console, print_error
11
+
12
+ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
13
+
14
+ app = typer.Typer(help="Scaffold new agents, workflows, and tools.")
15
+
16
+
17
+ def _get_jinja_env() -> Environment:
18
+ return Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), keep_trailing_newline=True)
19
+
20
+
21
+ def render_agent_template(
22
+ name: str,
23
+ provider: str = "ollama",
24
+ model: str = "llama3.1:8b",
25
+ orchestration_pattern: str = "react",
26
+ tools: list[str] | None = None,
27
+ temperature: float = 0.3,
28
+ max_tokens: int = 2048,
29
+ description: str = "",
30
+ ) -> str:
31
+ """Render an agent YAML template."""
32
+ env = _get_jinja_env()
33
+ template = env.get_template("agent.yaml.j2")
34
+ display_name = name.replace("-", " ").replace("_", " ").title()
35
+ return template.render(
36
+ name=name,
37
+ display_name=display_name,
38
+ description=description or f"Agent {display_name}",
39
+ provider=provider,
40
+ model=model,
41
+ orchestration_pattern=orchestration_pattern,
42
+ tools=tools or [],
43
+ temperature=temperature,
44
+ max_tokens=max_tokens,
45
+ )
46
+
47
+
48
+ def render_workflow_template(
49
+ name: str,
50
+ description: str = "",
51
+ ) -> str:
52
+ """Render a workflow YAML template."""
53
+ env = _get_jinja_env()
54
+ template = env.get_template("workflow.yaml.j2")
55
+ return template.render(
56
+ name=name,
57
+ description=description or f"Workflow {name}",
58
+ )
59
+
60
+
61
+ def render_tool_template(
62
+ name: str,
63
+ description: str = "A custom tool",
64
+ ) -> str:
65
+ """Render a tool Python file template."""
66
+ env = _get_jinja_env()
67
+ template = env.get_template("tool.py.j2")
68
+ # Convert snake_case name to PascalCase class name
69
+ class_name = "".join(part.capitalize() for part in name.split("_"))
70
+ return template.render(
71
+ name=name,
72
+ description=description,
73
+ class_name=class_name,
74
+ )
75
+
76
+
77
+ @app.command("agent")
78
+ def new_agent(
79
+ name: str = typer.Argument(..., help="Agent name (e.g., my-bot)"),
80
+ provider: str = typer.Option("ollama", "--provider", help="LLM provider"),
81
+ model: str = typer.Option("llama3.1:8b", "--model", help="Model name"),
82
+ orchestration: str = typer.Option("react", "--orchestration", help="Orchestration pattern"),
83
+ tools: Optional[list[str]] = typer.Option(None, "--tools", help="Tools to include"),
84
+ output_dir: str = typer.Option("./config/agents", "--output-dir", help="Output directory"),
85
+ force: bool = typer.Option(False, "--force", help="Overwrite existing file"),
86
+ ) -> None:
87
+ """Scaffold a new agent YAML configuration."""
88
+ out = Path(output_dir)
89
+ out.mkdir(parents=True, exist_ok=True)
90
+ dest = out / f"{name}.agent.yaml"
91
+
92
+ if dest.exists() and not force:
93
+ print_error(f"File already exists: {dest}. Use --force to overwrite.")
94
+ raise typer.Exit(code=0)
95
+
96
+ content = render_agent_template(
97
+ name=name,
98
+ provider=provider,
99
+ model=model,
100
+ orchestration_pattern=orchestration,
101
+ tools=tools,
102
+ )
103
+ dest.write_text(content)
104
+ console.print(
105
+ Panel(
106
+ f"[green]Created agent:[/green] {dest}\n\n"
107
+ f" Provider: {provider}\n"
108
+ f" Model: {model}\n"
109
+ f" Pattern: {orchestration}",
110
+ title="astromesh new agent",
111
+ border_style="green",
112
+ )
113
+ )
114
+
115
+
116
+ @app.command("workflow")
117
+ def new_workflow(
118
+ name: str = typer.Argument(..., help="Workflow name"),
119
+ output_dir: str = typer.Option("./config/workflows", "--output-dir", help="Output directory"),
120
+ force: bool = typer.Option(False, "--force", help="Overwrite existing file"),
121
+ ) -> None:
122
+ """Scaffold a new workflow YAML configuration."""
123
+ out = Path(output_dir)
124
+ out.mkdir(parents=True, exist_ok=True)
125
+ dest = out / f"{name}.workflow.yaml"
126
+
127
+ if dest.exists() and not force:
128
+ print_error(f"File already exists: {dest}. Use --force to overwrite.")
129
+ raise typer.Exit(code=0)
130
+
131
+ content = render_workflow_template(name=name)
132
+ dest.write_text(content)
133
+ console.print(
134
+ Panel(
135
+ f"[green]Created workflow:[/green] {dest}",
136
+ title="astromesh new workflow",
137
+ border_style="green",
138
+ )
139
+ )
140
+
141
+
142
+ @app.command("tool")
143
+ def new_tool(
144
+ name: str = typer.Argument(..., help="Tool name (snake_case)"),
145
+ description: str = typer.Option("A custom tool", "--description", help="Tool description"),
146
+ output_dir: str = typer.Option(".", "--output-dir", help="Output directory"),
147
+ force: bool = typer.Option(False, "--force", help="Overwrite existing file"),
148
+ ) -> None:
149
+ """Scaffold a new custom tool Python file."""
150
+ out = Path(output_dir)
151
+ out.mkdir(parents=True, exist_ok=True)
152
+ dest = out / f"{name}.py"
153
+
154
+ if dest.exists() and not force:
155
+ print_error(f"File already exists: {dest}. Use --force to overwrite.")
156
+ raise typer.Exit(code=0)
157
+
158
+ content = render_tool_template(name=name, description=description)
159
+ dest.write_text(content)
160
+ console.print(
161
+ Panel(
162
+ f"[green]Created tool:[/green] {dest}\n\n"
163
+ f" Class: {name.replace('_', ' ').title().replace(' ', '')}Tool\n"
164
+ f" Description: {description}",
165
+ title="astromesh new tool",
166
+ border_style="green",
167
+ )
168
+ )
@@ -0,0 +1,38 @@
1
+ """astromeshctl peers 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 peer nodes.")
10
+
11
+
12
+ @app.command("list")
13
+ def list_peers(json: bool = typer.Option(False, "--json", help="Output as JSON")):
14
+ """List configured peer nodes."""
15
+ try:
16
+ data = api_get("/v1/system/status")
17
+ if json:
18
+ print_json({"peers": data.get("peers", [])})
19
+ return
20
+
21
+ peers = data.get("peers", [])
22
+ if not peers:
23
+ console.print("[dim]No peers configured.[/dim]")
24
+ return
25
+
26
+ table = Table(title="Peer Nodes")
27
+ table.add_column("Name", style="cyan")
28
+ table.add_column("URL", style="green")
29
+ table.add_column("Services", style="dim")
30
+
31
+ for peer in peers:
32
+ services = ", ".join(peer.get("services", []))
33
+ table.add_row(peer.get("name", ""), peer.get("url", ""), services)
34
+
35
+ console.print(table)
36
+ except Exception:
37
+ print_error("Daemon not reachable.")
38
+ raise typer.Exit(code=0)