Files
MeshTerm/meshterm/tools/channels.py
T
Jean-Pierre MartineauandClaude Opus 5 109fe7645d Move every command onto the seam, and give the plain face its own premise
Forty-nine leaf commands now state a Report instead of printing one, so --json
works on all of them at once rather than on the two that grew a branch for it.
The two hand-rolled JSON paths are gone: devices no longer builds a payload dict
of its own, and its empty scan exits 5 like the plain path always did rather than
0 — a rendering flag has no business moving the report.

The plain face changes premise along with it. It used to be a serialisation
format a person could squint at, and several of its rules existed only to make
splitting safe. Splitting is the machine face's job now, so:

- Names are bare. Alignment is the delimiter; the escaping under the quoting
  stays, because nothing else stops a name a node broadcast from ending its own
  record.
- Times are ages. "Heard recently?" is the question a contacts listing is opened
  to ask, and an ISO instant makes the reader do arithmetic to answer it.
  --absolute puts the instants back; an appointment, the device clock and a live
  capture's own column were never ages and still aren't.
- A route takes arrows and a path keeps its commas. The lexicon has always split
  these and the output never did, so a route line looked pasteable into --path,
  which it is not.
- The listings gain what was cut for parser hygiene: contacts gets the HASH
  --path takes and a LOCATION the model has always carried, preferences gets the
  DESCRIPTION already written in its registry, and the two live streams get
  pinned lanes — which is what let them have columns at all, and with them the
  last call site for the quoting.
- The four written pages wrap at 72 instead of printing 600-cell paragraphs. It
  is the one exception to "no wrapping", and the rule's own reason says why: a
  paragraph has no fields to land under the wrong headings.

info keeps its keys and gains a gloss on the four readings nobody can hold in
their head (93784 (1d 2h)); it is the one key/value block with no set behind it,
so there is no round trip to protect. config show is untouched, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:43:00 -04:00

366 lines
14 KiB
Python

"""The ``channels`` tool: create, join, and manage mesh channels.
Interactively it opens a full-screen channel manager (see :mod:`meshterm.ui.channels`) — a
first-class, phone-app-style experience for creating private channels, adding public ``#``
channels, joining with a key, importing a scanned ``meshcore://`` link, and sharing any
channel as a QR code. On the CLI it exposes ``list``, ``add``, ``join``, ``import``, and
``share`` subcommands for scripted use.
Channels are also *listed* by the ``chat`` tool for picking a conversation; this tool owns
everything to do with configuring the slots themselves.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from rich.console import Group
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core.channels import (
channel_hash,
derive_secret,
is_public_channel,
normalize_secret,
parse_share_url,
random_secret,
share_url,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Facts, Listing
@register
class ChannelsTool(Tool):
"""Create, join, share, and manage the device's mesh channels."""
name = "channels"
title = "Channels"
icon = "📻"
help = "Create, join, and share mesh channels"
category = "Message"
order = 20
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Open the channel manager (menu) or run a scripted action (CLI).
Args:
ctx: Shared application context.
params: A ``cli_action`` with its arguments (CLI), or empty for the menu.
Returns:
A :class:`ToolResult` summarizing what happened.
"""
action = params.get("cli_action")
if action is not None:
return await self._run_cli(ctx, action, params)
from ..ui.channels import manage_channels
changes = await manage_channels(ctx)
message = (
f"[ok]✓[/ok] applied [brand]{changes}[/brand] "
f"channel change{'' if changes == 1 else 's'}"
if changes
else None
)
return ToolResult(summary={"changes": changes}, message=message)
# -- CLI --------------------------------------------------------------------
async def _run_cli(self, ctx: AppContext, action: str, params: dict[str, Any]) -> ToolResult:
"""Dispatch a scripted CLI action."""
if action == "add":
return await self._cli_add(ctx, params)
if action == "join":
return await self._cli_join(ctx, params)
if action == "import":
return await self._cli_import(ctx, params)
if action == "share":
return await self._cli_share(ctx, params)
if action == "clear":
return await self._cli_clear(ctx, params)
return await self._cli_list(ctx)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""List the configured channel slots."""
from ..ui.channels import read_channel_slots
device = await ctx.device()
slots = await read_channel_slots(device)
return ToolResult(
summary={"channels": len(slots)},
report=(_channel_listing(slots),),
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
)
async def _cli_add(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Add a public, private-random, or explicitly-keyed channel on a slot."""
from ..ui.channels import write_channel
device = await ctx.device()
idx = int(params["index"])
name = str(params["name"])
if name.startswith("#"): # public: firmware derives the key from the name
await write_channel(ctx, device, idx, name, None)
secret = derive_secret(name)
else:
secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret()
await write_channel(ctx, device, idx, name, secret)
self._show_qr(ctx, share_url(name, secret))
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret),),
)
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Join a channel from a name and an explicit key."""
from ..ui.channels import write_channel
device = await ctx.device()
idx = int(params["index"])
name = str(params["name"])
secret = normalize_secret(str(params["secret"]))
await write_channel(ctx, device, idx, name, secret)
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret, shown=False),),
)
async def _cli_import(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Import a channel from a ``meshcore://channel/add`` link."""
parsed = parse_share_url(str(params["url"]))
if parsed is None:
raise typer.BadParameter("not a valid meshcore:// channel link")
name, secret = parsed
from ..ui.channels import write_channel
device = await ctx.device()
idx = int(params["index"])
await write_channel(ctx, device, idx, name, secret)
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret, shown=False),),
)
async def _cli_share(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print a channel's share link, with its QR code in the menu.
The one command that names a channel's key deliberately, so it is the one place
the secret and the share URL leave the app at all — every other channel document
carries the identity and stops there.
"""
from ..ui.channels import read_channel_slots
device = await ctx.device()
idx = int(params["index"])
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
if slot is None:
return ToolResult(
summary={"index": idx, "shared": False},
report=(_written(idx, None, None, shown=False),),
exit_code=exitcodes.NO_RESULT,
)
self._show_qr(ctx, share_url(slot.name, slot.secret))
return ToolResult(
summary={"index": idx, "shared": True},
report=(_written(idx, slot.name, slot.secret),),
)
async def _cli_clear(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Clear a channel slot, removing the channel from the device.
The scriptable face of the channel manager's *Clear this slot*: an empty name
makes the firmware read the slot as unused. The ``--yes`` gate lives on the CLI
command (a private channel's key is lost with the slot unless it's saved
elsewhere), mirroring the interactive flow's danger confirmation.
"""
from ..ui.channels import read_channel_slots, write_channel
device = await ctx.device()
idx = int(params["index"])
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
if slot is None:
return ToolResult(
summary={"index": idx, "cleared": False},
report=(_cleared(idx, False),),
exit_code=exitcodes.NO_RESULT,
)
await write_channel(ctx, device, idx, "", None) # empty name => the slot reads as unused
return ToolResult(
summary={"index": idx, "cleared": True},
report=(_cleared(idx, True),),
)
@staticmethod
def _show_qr(ctx: AppContext, url: str) -> None:
"""Draw a channel's share link as a QR code — in the menu, and only there.
The QR is for a phone pointed at the screen. Piped into a file it is a block of
block characters wrapped around the one thing that is actually the answer, so a
scripted run states the link and nothing else.
"""
from ..ui.qr import qr_text
from ..ui.surface import TuiUi
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``channels`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@channels_app.command("list", help="List the configured channel slots")
def _list_cmd() -> None:
run_tool_command(self, {"cli_action": "list"})
@channels_app.command("add", help="Add a channel (# name = public; else private)")
def _add_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
name: str = typer.Argument(..., help="Channel name (leading # = public)"),
secret: str | None = typer.Option(
None, "--secret", help="32-hex-char key (private only; random if omitted)"
),
) -> None:
run_tool_command(
self, {"cli_action": "add", "index": index, "name": name, "secret": secret}
)
@channels_app.command("join", help="Join a channel with its name and key")
def _join_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
name: str = typer.Argument(..., help="Channel name"),
secret: str = typer.Argument(..., help="32-hex-char (16-byte) key"),
) -> None:
run_tool_command(
self, {"cli_action": "join", "index": index, "name": name, "secret": secret}
)
@channels_app.command("import", help="Import a meshcore:// channel link")
def _import_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
url: str = typer.Argument(..., help="A meshcore://channel/add link"),
) -> None:
run_tool_command(self, {"cli_action": "import", "index": index, "url": url})
@channels_app.command("share", help="Print a channel's share link and QR code")
def _share_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
) -> None:
run_tool_command(self, {"cli_action": "share", "index": index})
@channels_app.command("clear", help="Clear a channel slot (removes it from the device)")
def _clear_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
yes: bool = typer.Option(
False, "--yes", help="Confirm removing the channel from this slot"
),
) -> None:
if not yes:
# A usage error, in the parser's own words and under its own status — not a
# red line printed onto whatever is reading this command's output.
raise typer.BadParameter(
"clearing a slot removes the channel (a private channel's key is lost "
"unless you have it saved). Re-run with --yes to confirm."
)
run_tool_command(self, {"cli_action": "clear", "index": index})
app.add_typer(channels_app, name=self.name)
def _channel_listing(slots: list) -> Listing:
"""The configured slots, one record each.
The one listing whose record *is* a shared shape rather than carrying one: a row that
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
out of a listing whose every column is already the channel.
"""
from ..ui import fields
from ..ui.report import Listing
return Listing(
key="channels",
columns=(
fields.integer("slot", "SLOT"),
fields.name("name", "NAME"),
fields.word("type", "TYPE"),
fields.hexid("hash", "HASH"),
),
rows=[
{
"slot": slot.idx,
"name": slot.name,
"type": "public" if slot.is_public else "private",
"hash": slot.hash,
}
for slot in slots
],
)
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
All four write or read the same thing and all four know the key they wrote, so all
four state the same shape. The plain face prints the share URL alone where the caller
asked to be given something to pass on (``add``, ``share``) and nothing at all where
it did not (``join``, ``import``): those two were handed the key, and reading it back
to them is not an answer.
Args:
idx: The slot index.
name: The channel name, or ``None`` for a slot that turned out to be empty.
secret: The 16-byte key, or ``None`` for an empty slot.
shown: Whether the plain face prints the URL.
Returns:
The facts block.
"""
from ..ui import fields
from ..ui.fields import ChannelRef
from ..ui.report import BARE, SILENT, Facts
channel = None
if name is not None and secret is not None:
public = is_public_channel(name, secret)
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
return Facts(
key="channel",
fields=(
fields.channel("channel"),
fields.hexid("secret", "secret"),
fields.word("url", "url"),
),
values={
"channel": channel,
"secret": secret.hex() if secret else None,
"url": share_url(name, secret) if name is not None and secret is not None else None,
},
shape=BARE if shown else SILENT,
bare="url",
)
def _cleared(idx: int, cleared: bool) -> Facts:
"""What ``channels clear`` did — nothing plain, since the status already said it."""
from ..ui import fields
from ..ui.report import SILENT, Facts
return Facts(
key="cleared",
fields=(fields.integer("slot", "slot"), fields.flag("cleared", "cleared")),
values={"slot": idx, "cleared": cleared},
shape=SILENT,
)