diff --git a/hermes_cli/connectors_cmd.py b/hermes_cli/connectors_cmd.py new file mode 100644 index 0000000000..de7ddbe4af --- /dev/null +++ b/hermes_cli/connectors_cmd.py @@ -0,0 +1,238 @@ +"""``hermes connectors``: list, connect, rename and remove hosted connector accounts.""" + +from __future__ import annotations + +import re +import sys +import time +from typing import Any, Callable, NoReturn + +_POLL_SECONDS = 2.0 +_DONE_STATES = frozenset({"connected", "skipped", "failed", "expired", "not_connected"}) +_SIGN_IN = "Sign in with `hermes login` to manage connectors." + + +class _CommandError(Exception): + """A user-facing failure: the message is printed and the command exits 1.""" + + +def _fail(message: str) -> NoReturn: + raise _CommandError(message) + + +def _valid_alias(alias: str) -> str: + from tools.connectors.targets import ALIAS_PATTERN + + if not re.match(ALIAS_PATTERN, alias): + _fail(f"Invalid name {alias!r}. Use 1 to 32 lowercase letters, digits or hyphens, " + "starting with a letter or digit.") + return alias + + +def _account_name(row: dict[str, Any]) -> str: + return str(row.get("alias") or row.get("label") or row.get("connectionId") or "") + + +def _display_name(row: dict[str, Any]) -> str: + return row["alias"] if row.get("alias") else f"{row.get('label') or row.get('connectionId')} (unnamed)" + + +def _app_accounts(app: str) -> list[dict[str, Any]]: + from tools.connectors.portal.client import PortalConnectorClient + + return [r for r in PortalConnectorClient().list_accounts() if str(r.get("connector", "")).lower() == app] + + +def _names_hint(app: str, rows: list[dict[str, Any]]) -> str: + names = sorted({_account_name(r) for r in rows}) + return f"Its accounts are: {', '.join(names)}." if names else f"You have no {app} account." + + +def _match_named(app: str, rows: list[dict[str, Any]], name: str) -> dict[str, Any]: + matches = [r for r in rows if r.get("alias") == name] or [ + r for r in rows if not r.get("alias") and r.get("label") == name] + if len(matches) != 1: + what = "No" if not matches else "More than one" + _fail(f"{what} {app} account is called {name!r}. {_names_hint(app, rows)}") + return matches[0] + + +def _list(args) -> int: + from tools.connectors.managed import managed_client + + rows = managed_client().list_connectors() + if not rows: + print("No connectors are available.") + return 0 + width = max(len(str(r.get("connector", ""))) for r in rows) + for row in sorted(rows, key=lambda r: str(r.get("connector", ""))): + state = "connected" if row.get("connected") else "not connected" + print(f"{str(row.get('connector', '')).ljust(width)} {state}") + return 0 + + +def _accounts(args) -> int: + from tools.connectors.portal.client import PortalConnectorClient + + app = (args.app or "").strip().lower() + rows = [r for r in PortalConnectorClient().list_accounts() + if not app or str(r.get("connector", "")).lower() == app] + retired = [r for r in rows if r.get("disabled")] + shown = rows if args.all else [r for r in rows if not r.get("disabled")] + if not shown and not retired: + print(f"You have no {app} account." if app else "You have no connected accounts.") + return 0 + table = [(str(r.get("connector", "")), _display_name(r), str(r.get("status", "")) + (" (retired)" if r.get("disabled") else "")) + for r in sorted(shown, key=lambda r: (str(r.get("connector", "")), _account_name(r)))] + if table: + app_width = max(len(row[0]) for row in table) + name_width = max(len(row[1]) for row in table) + for connector, name, status in table: + print(f"{connector.ljust(app_width)} {name.ljust(name_width)} {status}") + if retired and not args.all: + print(f"{len(retired)} retired (use --all to list)") + return 0 + + +def _wait_for_link(start, app: str) -> dict[str, Any]: + """Poll the operation until its one target settles; returns the last target snapshot.""" + printed_url = False + while True: + snapshot = start.operation.snapshot() + target = snapshot["targets"][0] + url = target.get("connect_url") + if url and not printed_url: + print(url) + print("Open this link in your browser to finish.") + printed_url = True + if target.get("state") in _DONE_STATES or snapshot.get("settled_at") is not None: + return target + if time.time() >= start.operation.deadline_at: + return target + time.sleep(_POLL_SECONDS) + + +def _report_connect(app: str, alias: str | None, target: dict[str, Any]) -> int: + from tools.tool_labels import app_title + + state = target.get("state") + if state == "connected": + if alias: + print(f"{app_title(app)} account {alias} is connected. " + f"Rename it anytime: hermes connectors rename {app} {alias} --to ") + else: + print(f"{app_title(app)} is connected.") + return 0 + if state == "skipped": + print(f"Skipped connecting {app_title(app)}.") + return 0 + detail = target.get("detail") or f"{app_title(app)} did not finish connecting." + print(detail, file=sys.stderr) + return 1 + + +def _connect(args) -> int: + from tools.connectors import account, live + from tools.connectors.contract import SettleReason + + app = args.app.strip().lower() + alias = _valid_alias(args.alias) if args.alias else None + start = account.find_or_start_operation( + [app], action="reconnect" if args.reconnect else "connect", profile_home=None, alias=alias) + try: + account.wait_for_prepare(start) + if start.failed: + _fail(f"Could not start connecting {app}. Try again in a moment.") + target = _wait_for_link(start, app) + except KeyboardInterrupt: + # The operation would otherwise stay open and block the next connect of this app. + start.operation.settle(SettleReason.continue_) + live.close(start.operation) + print("\nStopped waiting.") + return 130 + return _report_connect(app, alias, target) + + +def _rename(args) -> int: + from tools.connectors.gateway.errors import GatewayUnavailable, IdempotencyConflict, ToolGatewayError + from tools.connectors.portal.client import PortalConnectorClient + + app = args.app.strip().lower() + new = _valid_alias(args.to) + row = _match_named(app, _app_accounts(app), args.name) + try: + PortalConnectorClient().rename_account(row["connectionId"], new) + except IdempotencyConflict: + _fail(f"The name {new} is already used by another {app} account.") + except GatewayUnavailable as exc: + if exc.code == "connection_not_found": + _fail("That account no longer exists.") + _fail("This Nous Portal cannot rename connector accounts yet.") + except ToolGatewayError as exc: + if exc.status == 405: + _fail("This Nous Portal cannot rename connector accounts yet.") + raise + print(f"Renamed {app} account {args.name} to {new}.") + return 0 + + +def _pick_for_disconnect(app: str, alias: str | None) -> dict[str, Any]: + rows = _app_accounts(app) + if alias: + return _match_named(app, rows, alias) + live_rows = [r for r in rows if not r.get("disabled")] + if len(live_rows) == 1: + return live_rows[0] + if not live_rows: + _fail(f"You have no {app} account.") + _fail(f"You have more than one {app} account. Pass --alias to pick one. {_names_hint(app, live_rows)}") + + +def _disconnect(args) -> int: + from tools.connectors.portal.client import PortalConnectorClient + + app = args.app.strip().lower() + row = _pick_for_disconnect(app, args.alias) + name = _account_name(row) + if not args.yes: + if not sys.stdin.isatty(): + _fail("Refusing to remove an account without confirmation. Pass --yes to confirm.") + answer = input(f"Remove {app} account {name}? This signs Hermes out of it. [y/N] ").strip().lower() + if answer not in ("y", "yes"): + print("Nothing removed.") + return 0 + PortalConnectorClient().delete_account(row["connectionId"]) + print(f"Removed {app} account {name}.") + return 0 + + +_ACTIONS: dict[str, Callable[[Any], int]] = { + "list": _list, + "ls": _list, + "accounts": _accounts, + "connect": _connect, + "rename": _rename, + "disconnect": _disconnect, +} + + +def cmd_connectors(args) -> int: + from tools.connectors import connectors_available + from tools.connectors.gateway.errors import GatewayAuthError, ToolGatewayError + + handler = _ACTIONS.get(getattr(args, "connectors_action", None) or "list") + if handler is None: + print(f"Unknown connectors action: {args.connectors_action}", file=sys.stderr) + return 2 + if not connectors_available(): + print("Connectors are not available. Sign in with `hermes login` first.", file=sys.stderr) + return 1 + try: + return handler(args) + except _CommandError as exc: + print(str(exc), file=sys.stderr) + except GatewayAuthError: + print(_SIGN_IN, file=sys.stderr) + except ToolGatewayError as exc: + print(str(exc), file=sys.stderr) + return 1 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1c3d5827b0..ef0ea302d1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -389,6 +389,7 @@ from hermes_cli.subcommands.skills import build_skills_parser from hermes_cli.subcommands.pairing import build_pairing_parser from hermes_cli.subcommands.plugins import build_plugins_parser from hermes_cli.subcommands.mcp import build_mcp_parser +from hermes_cli.subcommands.connectors import build_connectors_parser from hermes_cli.subcommands.claw import build_claw_parser from hermes_cli.subcommands.vault import build_vault_parser from hermes_cli.subcommands.moa import build_moa_parser @@ -2812,7 +2813,7 @@ def cmd_console(args): _BUILTIN_SUBCOMMANDS = frozenset( { "acp", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "codex-runtime", "completion", - "computer-use", + "computer-use", "connectors", "config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor", "dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", @@ -3452,6 +3453,7 @@ def _build_cli_parser(): build_tools_parser(subparsers, cmd_tools=cmd_tools) build_computer_use_parser(subparsers) build_mcp_parser(subparsers, cmd_mcp=cmd_mcp) + build_connectors_parser(subparsers) build_sessions_parser(subparsers, cmd_sessions=_cmd_sessions_lazy) build_insights_parser(subparsers, cmd_insights=cmd_insights) build_usage_parser(subparsers) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 270f4b0d5b..1bcb57a01a 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -274,7 +274,7 @@ _RESERVED_NAMES = frozenset({"hermes", "default", "test", "tmp", "root", "sudo"} _HERMES_SUBCOMMANDS = frozenset({ "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools", - "mcp", "sessions", "insights", "version", "update", "uninstall", "profile", "plugins", "honcho", "acp", + "mcp", "connectors", "sessions", "insights", "version", "update", "uninstall", "profile", "plugins", "honcho", "acp", }) diff --git a/hermes_cli/subcommands/connectors.py b/hermes_cli/subcommands/connectors.py new file mode 100644 index 0000000000..b1ea321fcf --- /dev/null +++ b/hermes_cli/subcommands/connectors.py @@ -0,0 +1,36 @@ +"""``hermes connectors`` subcommand parser.""" + +from __future__ import annotations + + +def build_connectors_parser(subparsers) -> None: + """Attach the ``connectors`` subcommand to ``subparsers``.""" + from hermes_cli.connectors_cmd import cmd_connectors + + parser = subparsers.add_parser( + "connectors", help="Manage hosted app connectors and their accounts", + description="List, connect, rename and remove the app accounts Hermes uses through Nous Portal.") + actions = parser.add_subparsers(dest="connectors_action") + + actions.add_parser("list", aliases=["ls"], help="Show every connector and whether it is connected") + + accounts = actions.add_parser("accounts", help="Show connected accounts") + accounts.add_argument("app", nargs="?", help="Only show accounts of this app") + accounts.add_argument("--all", action="store_true", help="Also show retired accounts") + + connect = actions.add_parser("connect", help="Connect an app account in your browser") + connect.add_argument("app", help="App to connect, for example gmail") + connect.add_argument("--alias", help="Name for this account, for example work") + connect.add_argument("--reconnect", action="store_true", help="Repair an account that stopped working") + + rename = actions.add_parser("rename", help="Rename an account") + rename.add_argument("app", help="App the account belongs to") + rename.add_argument("name", help="Current name or label of the account") + rename.add_argument("--to", required=True, help="New name for the account") + + disconnect = actions.add_parser("disconnect", help="Remove an account") + disconnect.add_argument("app", help="App the account belongs to") + disconnect.add_argument("--alias", help="Name or label of the account to remove") + disconnect.add_argument("--yes", "-y", action="store_true", help="Skip the confirmation prompt") + + parser.set_defaults(func=cmd_connectors) diff --git a/tests/hermes_cli/test_connectors_cmd.py b/tests/hermes_cli/test_connectors_cmd.py new file mode 100644 index 0000000000..3b73f0ff20 --- /dev/null +++ b/tests/hermes_cli/test_connectors_cmd.py @@ -0,0 +1,61 @@ +"""Behaviour contracts for ``hermes connectors``.""" + +from __future__ import annotations + +import argparse + +import pytest + +from hermes_cli.connectors_cmd import cmd_connectors +from hermes_cli.subcommands.connectors import build_connectors_parser + + +def _parse(*argv: str) -> argparse.Namespace: + parser = argparse.ArgumentParser() + build_connectors_parser(parser.add_subparsers(dest="command")) + return parser.parse_args(list(argv)) + + +@pytest.fixture +def portal(monkeypatch): + calls: list[tuple[str, str]] = [] + + class FakePortal: + def list_accounts(self): + return [ + {"connectionId": "conn-work", "connector": "gmail", "status": "active", "label": "a@x.test", + "alias": "work", "active": True}, + {"connectionId": "conn-home", "connector": "gmail", "status": "active", "label": "b@x.test", + "alias": "home", "active": True}, + ] + + def rename_account(self, connection_id, alias): + calls.append((connection_id, alias)) + return {"connectionId": connection_id, "connector": "gmail", "alias": alias, "label": "a@x.test"} + + monkeypatch.setattr("tools.connectors.portal.client.PortalConnectorClient", FakePortal) + monkeypatch.setattr("tools.connectors.connectors_available", lambda: True, raising=False) + return calls + + +def test_rename_parses_app_name_and_new_name(): + args = _parse("connectors", "rename", "gmail", "work", "--to", "home") + + assert (args.connectors_action, args.app, args.name, args.to) == ("rename", "gmail", "work", "home") + assert args.func is cmd_connectors + + +def test_rename_targets_the_named_account(portal, capsys): + rc = cmd_connectors(_parse("connectors", "rename", "gmail", "work", "--to", "office")) + + assert rc == 0 + assert portal == [("conn-work", "office")] + assert "office" in capsys.readouterr().out + + +def test_rename_to_an_invalid_name_calls_nothing(portal, capsys): + rc = cmd_connectors(_parse("connectors", "rename", "gmail", "work", "--to", "Bad Name")) + + assert rc == 1 + assert portal == [] + assert "lowercase" in capsys.readouterr().err