Files
MeshTerm/meshtools/cli.py
T
Jean-Pierre MartineauandClaude Opus 4.8 421781f13d Fix auto-path traces, harden contacts retrieval, add device config tooling
Trace:
- Auto-path now ends every route at the target's own hash (verified on
  hardware: a trace only replies when its destination is the final hop), so
  direct neighbors and learned multi-hop routes both resolve correctly.
- Retry the transient "no event received during contacts retrieval" blip and
  surface a clean DeviceCommandError instead of a raw traceback.
- Route summary annotates each node with its hash at the command's path-hash
  width, wrapping after an arrow when the line is too long.
- Default trace count changed from 5 to 3.

Also includes the in-progress device-config feature (config_io, device_config,
config tool, config editor UI, tests) and related supporting edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:19:21 -04:00

163 lines
5.3 KiB
Python

"""Typer entry point.
Global options are parsed once in the callback, which builds the shared
:class:`~meshtools.context.AppContext`. Running with no subcommand launches the
interactive menu; otherwise the selected tool's subcommand runs. Both paths funnel
through :func:`run_tool_command` / :func:`run_menu`, so behavior stays identical.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Optional
import typer
from .context import AppContext
from .core.config import Settings
from .core.connection import DeviceCommandError
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.repository import Repository
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui.theme import make_console
app = typer.Typer(
add_completion=False,
no_args_is_help=False,
rich_markup_mode="rich",
help="MeshTools — a modern toolkit for tuning and exploring your MeshCore mesh.",
)
# The context built by the callback and consumed by subcommands within one process.
_state: Optional[AppContext] = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Device profile."),
port: Optional[str] = typer.Option(None, "--port", help="Serial port override."),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator."),
db_path: Optional[Path] = typer.Option(None, "--db", help="SQLite database path."),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging."),
) -> None:
"""Build the application context and dispatch to the menu or a subcommand.
Args:
ctx: The Click/Typer context.
profile: Named device profile to use.
port: Explicit serial port, overriding the profile.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
json_output: Request machine-readable output from tools.
quiet: Suppress console logging (file logging continues).
"""
global _state
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
console = make_console()
configure_logging(
console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output
)
app_ctx = AppContext(
console=console,
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(settings.config_dir / "devices.json"),
profile=settings.resolve_profile(profile),
mock=mock,
port_override=port,
json_output=json_output,
explicit_selection=profile is not None or port is not None,
)
_state = app_ctx
ctx.call_on_close(app_ctx.repo.close)
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
def run_tool_command(tool: Tool, params: dict) -> None:
"""Execute a tool from a CLI subcommand and render its result.
Builds and tears down the device connection within a single event loop so the
``meshcore`` client is never used across loops.
Args:
tool: The tool to run.
params: Parameters parsed from the subcommand's options.
"""
assert _state is not None # set by the callback before any subcommand runs
try:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError) as exc:
# Expected user-facing error (ambiguous/absent device, bad config value, or a
# transient command failure): show the message, not a traceback.
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
async def _drive(coro, ctx: AppContext) -> None:
"""Await a coroutine then disconnect the device (but keep the repo open).
Args:
coro: The coroutine to run (a tool execution or the menu loop).
ctx: The application context whose device should be closed afterward.
"""
try:
await coro
finally:
if ctx._device is not None:
await ctx._device.disconnect()
ctx._device = None
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
"""Run a tool and print its closing message and artifact list.
Args:
tool: The tool to execute.
params: Parameters for the tool.
ctx: The application context.
Returns:
The tool's :class:`ToolResult`.
"""
result = await tool.execute(ctx, params)
if result.message:
ctx.console.print(result.message)
for artifact in result.artifacts:
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
return result
def _register_all() -> None:
"""Register every tool's CLI subcommand with the Typer app."""
for tool in all_tools():
tool.register_cli(app)
_register_all()
def main() -> None:
"""Console-script entry point."""
app()
if __name__ == "__main__":
main()