Files
MeshTerm/meshtools/tools/base.py
T
Jean-Pierre MartineauandClaude Opus 4.8 7330796829 Add a first-class channel manager with QR sharing and reordering
Replace the primitive channel editor that lived inside the config menu with
a dedicated `channels` tool: list the device's slots and, per channel, show a
scannable QR share code, reveal the key, open it in chat, rename/re-key, or
clear it. Create channels four ways — new private (random key), public `#`
(key derived from the name), join-by-key, or import a `meshcore://` link — with
every change written to the device immediately.

- core/channels.py: pure, hardware-free logic (secret derivation, tolerant key
  parsing, share-URL round-tripping, channel hash). full_channel_hash exposes
  the complete digest; the short channel_hash is its leading byte.
- ui/qr.py: half-block QR rendering (segno), drawn black-on-white so it scans
  regardless of terminal theme.
- ui/channels.py: the interactive manager. Channels display by their real name;
  reorder rows by dragging with the arrow keys; the key view shows the full hash
  with the first byte highlighted.
- ui/tui: a ReorderScreen (grab/move/drop) plus a ctx.ui.reorder surface method.
- tools/channels.py: the tool + `channels list/add/join/import/share` CLI.
- Pin Chat + Channels to the top of the main menu via an explicit category order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:23:47 -04:00

195 lines
6.6 KiB
Python

"""The tool plugin contract and registry.
A *tool* is one menu option / CLI subcommand. Subclass :class:`Tool`, decorate it with
:func:`register`, and implement :meth:`Tool.run`. The same registry drives both the
interactive menu and the Typer CLI, and :meth:`Tool.execute` wraps every invocation in a
logged ``runs`` row, so new tools get persistence and logging for free.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING: # avoid importing typer/context at module load for fast startup
import typer
from ..context import AppContext
@dataclass(slots=True)
class ToolResult:
"""The outcome of a tool execution.
Attributes:
summary: JSON-serializable summary persisted to the run record.
message: Optional human-readable closing message.
artifacts: Paths to any files produced (e.g. generated visualizations).
"""
summary: dict[str, Any] = field(default_factory=dict)
message: Optional[str] = None
artifacts: list[str] = field(default_factory=list)
class Tool(ABC):
"""Base class for all MeshTools features.
Class Attributes:
name: CLI/menu identifier (kebab-case), unique across tools.
help: One-line description shown in the menu and ``--help``.
category: Grouping label used to organize the interactive menu.
order: Sort key within a category (lower sorts first).
"""
name: str = ""
help: str = ""
category: str = "General"
order: int = 100
@abstractmethod
async def run(self, ctx: "AppContext", params: dict[str, Any]) -> ToolResult:
"""Execute the tool's work.
Args:
ctx: Shared application context (console, device, repository, ...).
params: Validated parameters for this invocation.
Returns:
A :class:`ToolResult` describing the outcome.
"""
async def prompt_params(self, ctx: "AppContext") -> dict[str, Any]:
"""Interactively gather parameters for the menu.
The default implementation requires no parameters. Tools override this to ask
the user via ``questionary``.
Args:
ctx: Shared application context.
Returns:
A parameter dict compatible with :meth:`run`.
"""
return {}
def register_cli(self, app: "typer.Typer") -> None:
"""Register this tool as a Typer subcommand.
The default registers a no-argument command. Tools with parameters override this
to declare typed options, then delegate to :meth:`execute`.
Args:
app: The Typer application to add the command to.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _command() -> None:
run_tool_command(self, {})
async def execute(self, ctx: "AppContext", params: dict[str, Any]) -> ToolResult:
"""Run the tool wrapped in run-logging and error capture.
Opens a ``runs`` row before execution and closes it with the final status and
summary afterward, regardless of success.
Args:
ctx: Shared application context.
params: Parameters for this invocation.
Returns:
The :class:`ToolResult` from :meth:`run`.
Raises:
Exception: Re-raises any error from :meth:`run` after recording it.
"""
from ..core.connection import DeviceCommandError
from ..core.device_config import DeviceConfigError
from ..core.selection import DeviceSelectionError
run_id = ctx.repo.start_run(self.name, params, ctx.profile_name)
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, params)
try:
result = await self.run(ctx, {**params, "_run_id": run_id})
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError) as exc:
# Expected user-facing condition (no/ambiguous device, bad config value, or
# a transient command failure): record it but don't dump a traceback;
# callers print the message cleanly.
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
ctx.log.debug("run %s aborted: %s", run_id, exc)
raise
except Exception as exc: # noqa: BLE001 - we record then re-raise
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
ctx.log.exception("run %s failed: %s", run_id, exc)
raise
ctx.repo.finish_run(run_id, "ok", result.summary)
ctx.log.debug("run %s ok", run_id)
return result
_REGISTRY: dict[str, Tool] = {}
#: Explicit menu ordering for tool categories. Messaging (Chat + Channels) leads because it
#: holds the first-class, everyday features; the rest follow in a natural workflow order.
#: Categories not listed here sort last, alphabetically, so a new category still appears.
_CATEGORY_ORDER = ["Messaging", "Device", "Diagnostics", "Optimization", "Data"]
def _category_rank(category: str) -> int:
"""Return a category's sort rank (its position in ``_CATEGORY_ORDER``, else last)."""
try:
return _CATEGORY_ORDER.index(category)
except ValueError:
return len(_CATEGORY_ORDER)
def register(cls: type[Tool]) -> type[Tool]:
"""Class decorator that instantiates a tool and adds it to the registry.
Args:
cls: The :class:`Tool` subclass to register.
Returns:
The unchanged class (so the decorator is transparent).
Raises:
ValueError: If the tool has no ``name`` or the name is already registered.
"""
instance = cls()
if not instance.name:
raise ValueError(f"{cls.__name__} must define a non-empty 'name'.")
if instance.name in _REGISTRY:
raise ValueError(f"Duplicate tool name: {instance.name!r}")
_REGISTRY[instance.name] = instance
return cls
def all_tools() -> list[Tool]:
"""Return all registered tools sorted by category rank, then order, then name.
Categories are ordered by :data:`_CATEGORY_ORDER` (Messaging first), so the first-class
Chat and Channels tools lead the menu; within a category, ``order`` then ``name`` break
ties.
Returns:
The registered tool instances in stable menu order.
"""
return sorted(
_REGISTRY.values(),
key=lambda t: (_category_rank(t.category), t.category, t.order, t.name),
)
def get_tool(name: str) -> Optional[Tool]:
"""Look up a registered tool by name.
Args:
name: The tool's ``name``.
Returns:
The tool instance, or ``None`` if not registered.
"""
return _REGISTRY.get(name)