diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 77ca79105b..624f955d2e 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -334,6 +334,12 @@ def _plugin_command_install_hint(prog: str, value: str): return None +def _moved_command_hint(prog: str, value: str) -> str: + """``hermes auth spotify`` / ``hermes spotify`` after Spotify left core: where the command went.""" + from hermes_cli.left_core_migration import moved_command_hint + return moved_command_hint(prog, value) + + class HermesArgumentParser(argparse.ArgumentParser): """argparse parser whose unknown-subcommand error is three short lines, not a 70-name dump. @@ -350,9 +356,12 @@ class HermesArgumentParser(argparse.ArgumentParser): lines = [f"{self.prog}: '{value}' is not a `{self.prog}` command."] close = difflib.get_close_matches(str(value), list(action.choices), n=3, cutoff=0.6) install = _plugin_command_install_hint(self.prog, str(value)) + moved = "" if install else _moved_command_hint(self.prog, str(value)) if install: # A provider that left core (``hermes honcho``) registers its command only once installed. lines.append(f"The '{value}' memory plugin is not installed. Install it with: {install}") + elif moved: + lines.append(moved) elif close: lines.append(f"Did you mean: {', '.join(close)}?") lines.append(f"Run `{self.prog} --help` to see all commands.") diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 448799b08a..3f1e8e5307 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6,7 +6,7 @@ only I/O primitives (cross-process flock, atomic 0o600 writes). - ``resolve_provider()`` picks the active provider via the documented priority chain. - ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in - ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify``/``auth_openrouter`` and are + ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_openrouter`` and are re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface.""" from __future__ import annotations @@ -81,9 +81,6 @@ from hermes_cli.auth_codex import ( _probe_codex_quota_restored, _read_codex_tokens, _refresh_codex_auth_tokens, _refresh_expired_codex_probe_token, _save_codex_tokens, clear_codex_pool_quota_cooldowns, refresh_codex_oauth_pure, resolve_codex_runtime_credentials) -from hermes_cli.auth_spotify import ( - _refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command, - resolve_spotify_runtime_credentials) from hermes_cli.auth_openrouter import _openrouter_pkce_login from hermes_cli.auth_qwen import ( _qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens, @@ -102,8 +99,7 @@ from hermes_cli.auth_constants import ( STEPFUN_STEP_PLAN_CN_BASE_URL, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE, XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, - DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, SPOTIFY_DOCS_URL, - DEFAULT_SPOTIFY_SCOPE, SERVICE_PROVIDER_NAMES, LMSTUDIO_NOAUTH_PLACEHOLDER, + LMSTUDIO_NOAUTH_PLACEHOLDER, ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, CODEX_RATE_LIMITED_CODE, AuthError, _nous_err, httpx) logger = logging.getLogger(__name__) @@ -924,14 +920,14 @@ def mark_provider_active_if_unset(provider_id: str) -> None: def is_known_auth_provider(provider_id: str) -> bool: normalized = (provider_id or "").strip().lower() - return _registry_lookup(normalized) is not None or normalized in SERVICE_PROVIDER_NAMES + return _registry_lookup(normalized) is not None def get_auth_provider_display_name(provider_id: str) -> str: normalized = (provider_id or "").strip().lower() if normalized in PROVIDER_REGISTRY: return PROVIDER_REGISTRY[normalized].name - return SERVICE_PROVIDER_NAMES.get(normalized, provider_id) + return provider_id def is_runtime_provider_routable(provider_id: str) -> bool: @@ -2182,8 +2178,8 @@ def _get_aws_sdk_auth_status(target: str) -> dict[str, Any]: def get_auth_status(provider_id: Optional[str] = None) -> dict[str, Any]: - """Generic auth status dispatcher: bespoke builders (``OAUTH_PROVIDER_FLOWS`` plus Spotify / - Azure Foundry) first, then the registry ``auth_type`` so a whole provider class (e.g. every + """Generic auth status dispatcher: bespoke builders (``OAUTH_PROVIDER_FLOWS`` plus Azure + Foundry) first, then the registry ``auth_type`` so a whole provider class (e.g. every external-process ACP backend) gets a real status. Builders are looked up by NAME at call time so tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply.""" target = (provider_id or get_active_provider() or "").strip().lower() @@ -2202,7 +2198,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> dict[str, Any]: # auth_type-keyed fallbacks below. _BESPOKE_STATUS_FUNCTIONS: dict[str, str] = { **{pid: flow.status_fn for pid, flow in OAUTH_PROVIDER_FLOWS.items()}, - "spotify": "get_spotify_auth_status", "azure-foundry": "_get_azure_foundry_auth_status"} _STATUS_BY_AUTH_TYPE: dict[str, str] = { "external_process": "get_external_process_provider_status", diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index a478372262..63b9c2acaf 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -665,12 +665,20 @@ def auth_refresh_command(args) -> None: f"status still: {status}") +def _moved_auth_hint(action: str, provider: str) -> str: + """``hermes auth status|logout spotify`` after Spotify left core for its plugin's own command.""" + from hermes_cli.left_core_migration import moved_command_hint + return moved_command_hint("hermes auth", provider, action) + + def auth_status_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "") or "") if not provider: - raise SystemExit("Provider is required. Example: `hermes auth status spotify`.") + raise SystemExit("Provider is required. Example: `hermes auth status nous`.") if dispatch_plugin_auth("status", args, provider): return + if moved := _moved_auth_hint("status", provider): + raise SystemExit(moved) if provider in auth_mod.SINGLE_USE_REFRESH_POOL_PROVIDERS: load_pool(provider) # runs the forked-grant heal first so the report reflects the consolidated grant status = auth_mod.get_auth_status(provider) @@ -700,20 +708,11 @@ def auth_logout_command(args) -> None: raw_provider = getattr(args, "provider", None) if dispatch_plugin_auth("logout", args, _normalize_provider(raw_provider or "")): return + if moved := _moved_auth_hint("logout", _normalize_provider(raw_provider or "")): + raise SystemExit(moved) auth_mod.logout_command(SimpleNamespace(provider=raw_provider)) -def auth_spotify_command(args) -> None: - action = str(getattr(args, "spotify_action", "") or "login").strip().lower() - if action in {"", "login"}: - auth_mod.login_spotify_command(args) - return - handler = {"status": auth_status_command, "logout": auth_logout_command}.get(action) - if handler is None: - raise SystemExit(f"Unknown Spotify auth action: {action}") - handler(SimpleNamespace(provider="spotify")) - - def _print_bedrock_status() -> None: """Show AWS Bedrock credential status (not in the pool — uses boto3 chain).""" try: @@ -901,8 +900,7 @@ def auth_upgrade_command(args) -> None: _AUTH_ACTIONS = { "add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command, "reset": auth_reset_command, "priority": auth_priority_command, "refresh": auth_refresh_command, "status": auth_status_command, - "logout": auth_logout_command, "upgrade": auth_upgrade_command, - "spotify": auth_spotify_command} + "logout": auth_logout_command, "upgrade": auth_upgrade_command} def auth_command(args) -> None: diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index f975310bc6..1ce3257d32 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -103,12 +103,6 @@ XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600 QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 -DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL = "https://accounts.spotify.com" -DEFAULT_SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1" -DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback" -SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify" -SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard" -SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # OpenRouter PKCE (https://openrouter.ai/docs/guides/overview/auth/oauth): the "token" endpoint # mints a plain user-controlled API key; there is no refresh token. OPENROUTER_AUTH_URL = "https://openrouter.ai/auth" @@ -116,13 +110,6 @@ OPENROUTER_AUTH_KEYS_URL = "https://openrouter.ai/api/v1/auth/keys" OPENROUTER_OAUTH_DOCS_URL = "https://openrouter.ai/docs/guides/overview/auth/oauth" OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" -DEFAULT_SPOTIFY_SCOPE = " ".join(( - "user-modify-playback-state", "user-read-playback-state", "user-read-currently-playing", - "user-read-recently-played", "playlist-read-private", "playlist-read-collaborative", - "playlist-modify-public", "playlist-modify-private", "user-library-read", "user-library-modify", -)) -SERVICE_PROVIDER_NAMES: dict[str, str] = {"spotify": "Spotify"} - # LM Studio's default no-auth mode still needs *some* non-empty bearer for the API-key code paths to # treat the provider as configured. Sent only to LM Studio, never to a remote service. LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key" @@ -162,7 +149,6 @@ def _provider_error_factory(provider: str) -> Callable[..., AuthError]: _nous_err = _provider_error_factory("nous") _xai_err = _provider_error_factory("xai-oauth") _codex_err = _provider_error_factory("openai-codex") -_spotify_err = _provider_error_factory("spotify") _qwen_err = _provider_error_factory("qwen-oauth") _minimax_err = _provider_error_factory("minimax-oauth") _openrouter_err = _provider_error_factory("openrouter") diff --git a/hermes_cli/auth_spotify.py b/hermes_cli/auth_spotify.py deleted file mode 100644 index edca376f8a..0000000000 --- a/hermes_cli/auth_spotify.py +++ /dev/null @@ -1,390 +0,0 @@ -"""Spotify OAuth (loopback PKCE) login, refresh and runtime credentials. - -Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported -lazily per function so ``hermes_cli.auth.`` patches still intercept and no cycle forms. -""" - -from __future__ import annotations - -import logging -import uuid -import webbrowser -from datetime import datetime, timezone -from typing import Any, Dict, Optional, Tuple -from urllib.parse import urlencode, urlparse -from hermes_cli.auth_constants import ( - AuthError, DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, DEFAULT_SPOTIFY_REDIRECT_URI, - DEFAULT_SPOTIFY_SCOPE, SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, SPOTIFY_DASHBOARD_URL, SPOTIFY_DOCS_URL, - _spotify_err, httpx, -) -from hermes_cli.auth_device_flow import ( - _bind_loopback_callback_server, _make_loopback_callback_handler, _pkce_code_challenge, - _pkce_code_verifier, _serve_loopback_callback) - -logger = logging.getLogger("hermes_cli.auth") - - -def _clean(value: Any) -> str: - return str(value or "").strip() - - -def _spotify_scope_string(raw_scope: Optional[str] = None) -> str: - """Requested scope, whitespace-normalized and de-duplicated (order kept).""" - return " ".join(dict.fromkeys((raw_scope or DEFAULT_SPOTIFY_SCOPE).split())) - - -def _spotify_setting( - state: Optional[dict[str, Any]], state_key: str, env_vars: tuple[str, ...], default: str, *, - explicit: Optional[str] = None, strip_slash: bool = False, -) -> str: - """First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*.""" - from hermes_cli.config import get_env_value - candidates = ( - explicit, *(get_env_value(var) for var in env_vars), - state.get(state_key) if isinstance(state, dict) else None, default, - ) - for candidate in candidates: - cleaned = _clean(candidate) - if strip_slash: - cleaned = cleaned.rstrip("/") - if cleaned: - return cleaned - return default - - -def _spotify_client_id(explicit: Optional[str] = None, state: Optional[dict[str, Any]] = None) -> str: - client_id = _spotify_setting( - state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit, - ) - if client_id: - return client_id - raise _spotify_err( - "Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.", - "spotify_client_id_missing", - ) - - -def _spotify_redirect_uri(explicit: Optional[str] = None, state: Optional[dict[str, Any]] = None) -> str: - return _spotify_setting( - state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"), - DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit, - ) - - -def _spotify_api_base_url(state: Optional[dict[str, Any]] = None) -> str: - return _spotify_setting( - state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True, - ) - - -def _spotify_accounts_base_url(state: Optional[dict[str, Any]] = None) -> str: - return _spotify_setting( - state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, - strip_slash=True, - ) - - -def _spotify_build_authorize_url( - *, client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: str, - accounts_base_url: str, -) -> str: - query = urlencode({ - "client_id": client_id, "response_type": "code", "redirect_uri": redirect_uri, - "scope": scope, "state": state, "code_challenge_method": "S256", - "code_challenge": code_challenge, - }) - return f"{accounts_base_url}/authorize?{query}" - - -def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: - parsed = urlparse(redirect_uri) - host = parsed.hostname or "" - problem = ( - "must use http://localhost or http://127.0.0.1." if parsed.scheme != "http" - else "must point to localhost or 127.0.0.1." if host not in {"127.0.0.1", "localhost"} - else "must include an explicit localhost port." if not parsed.port - else None - ) - if problem: - raise _spotify_err(f"Spotify PKCE redirect_uri {problem}", "spotify_redirect_invalid") - return host, parsed.port, parsed.path or "/" - - -def _spotify_wait_for_callback(redirect_uri: str, *, timeout_seconds: float = 180.0) -> dict[str, Any]: - host, port, path = _spotify_validate_redirect_uri(redirect_uri) - handler_cls, result = _make_loopback_callback_handler(path, display_name="Spotify") - server = _bind_loopback_callback_server( - host, port, handler_cls, err=_spotify_err, bind_failed_code="spotify_callback_bind_failed") - return _serve_loopback_callback( - server, result, timeout_seconds=timeout_seconds, err=_spotify_err, timeout_code="spotify_callback_timeout") - - -def _spotify_token_payload_to_state( - token_payload: dict[str, Any], *, client_id: str, redirect_uri: str, requested_scope: str, - accounts_base_url: str, api_base_url: str, previous_state: Optional[dict[str, Any]] = None, -) -> dict[str, Any]: - from hermes_cli.auth import _coerce_ttl_seconds - now = datetime.now(timezone.utc) - expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0)) - expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) - state = dict(previous_state or {}) - state.update({ - "client_id": client_id, "redirect_uri": redirect_uri, - "accounts_base_url": accounts_base_url, "api_base_url": api_base_url, - "scope": requested_scope, - "granted_scope": str(token_payload.get("scope") or requested_scope).strip(), - "token_type": _clean(token_payload.get("token_type", "Bearer") or "Bearer") or "Bearer", - "access_token": _clean(token_payload.get("access_token")), - "refresh_token": _clean(token_payload.get("refresh_token") or state.get("refresh_token")), - "obtained_at": now.isoformat(), "expires_at": expires_at.isoformat(), - "expires_in": expires_in, "auth_type": "oauth_pkce", - }) - return state - - -def _spotify_token_post( - accounts_base_url: str, data: dict[str, str], *, timeout_seconds: float, what: str, - failed_code: str, invalid_code: str, invalid_message: str, failed_suffix: str = "", - relogin_required: bool = False, -) -> dict[str, Any]: - """POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError.""" - try: - response = httpx.post( - f"{accounts_base_url}/api/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data=data, - timeout=timeout_seconds, - ) - except Exception as exc: - raise _spotify_err(f"Spotify {what} failed: {exc}", failed_code) from exc - - if response.status_code >= 400: - detail = response.text.strip() - raise _spotify_err( - f"Spotify {what} failed.{failed_suffix}" + (f" Response: {detail}" if detail else ""), - failed_code, relogin=relogin_required, - ) - payload = response.json() - if not isinstance(payload, dict) or not _clean(payload.get("access_token")): - raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required) - return payload - - -def _refresh_spotify_oauth_state(state: dict[str, Any], *, timeout_seconds: float = 20.0) -> dict[str, Any]: - refresh_token = _clean(state.get("refresh_token")) - if not refresh_token: - raise _spotify_err( - "Spotify refresh token missing. Run `hermes auth spotify` again.", - "spotify_refresh_token_missing", relogin=True, - ) - - client_id = _spotify_client_id(state=state) - accounts_base_url = _spotify_accounts_base_url(state) - payload = _spotify_token_post( - accounts_base_url, - {"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id}, - timeout_seconds=timeout_seconds, what="token refresh", failed_code="spotify_refresh_failed", - invalid_code="spotify_refresh_invalid", - invalid_message="Spotify refresh response did not include an access_token.", - failed_suffix=" Run `hermes auth spotify` again.", relogin_required=True, - ) - - return _spotify_token_payload_to_state( - payload, client_id=client_id, redirect_uri=_spotify_redirect_uri(state=state), - requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE), - accounts_base_url=accounts_base_url, api_base_url=_spotify_api_base_url(state), - previous_state=state, - ) - - -def resolve_spotify_runtime_credentials( - *, force_refresh: bool = False, refresh_if_expiring: bool = True, - refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, -) -> dict[str, Any]: - from hermes_cli.auth import _auth_store_lock, _is_expiring, _load_auth_store, _load_provider_state, _quarantine_flat_oauth_state, _refresh_spotify_oauth_state, _save_auth_store, _store_provider_state - with _auth_store_lock(): - auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "spotify") - if not state: - raise _spotify_err( - "Spotify is not authenticated. Run `hermes auth spotify` first.", "spotify_auth_missing", relogin=True, - ) - - should_refresh = bool(force_refresh) - if not should_refresh and refresh_if_expiring: - should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds) - if should_refresh: - try: - state = _refresh_spotify_oauth_state(state) - _store_provider_state(auth_store, "spotify", state, set_active=False) - _save_auth_store(auth_store) - except AuthError as exc: - if exc.relogin_required and state.get("refresh_token"): - _quarantine_flat_oauth_state(state, "spotify", exc) - try: - _store_provider_state(auth_store, "spotify", state, set_active=False) - _save_auth_store(auth_store) - except Exception as _save_exc: - logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc) - raise - - access_token = _clean(state.get("access_token")) - if not access_token: - raise _spotify_err( - "Spotify access token missing. Run `hermes auth spotify` again.", - "spotify_access_token_missing", relogin=True, - ) - - return { - "provider": "spotify", "access_token": access_token, "api_key": access_token, - "token_type": str(state.get("token_type", "Bearer") or "Bearer"), - "base_url": _spotify_api_base_url(state), - "scope": _clean(state.get("granted_scope") or state.get("scope")), - "client_id": _spotify_client_id(state=state), - "redirect_uri": _spotify_redirect_uri(state=state), "expires_at": state.get("expires_at"), - "refresh_token": _clean(state.get("refresh_token")), - } - - -def get_spotify_auth_status() -> dict[str, Any]: - from hermes_cli.auth import _is_expiring, get_provider_auth_state - state = get_provider_auth_state("spotify") - if not state: - return {"logged_in": False} - - expires_at = state.get("expires_at") - refresh_token = _clean(state.get("refresh_token")) - return { - "logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)), - "auth_type": state.get("auth_type", "oauth_pkce"), "client_id": state.get("client_id"), - "redirect_uri": state.get("redirect_uri"), - "scope": state.get("granted_scope") or state.get("scope"), "expires_at": expires_at, - "api_base_url": state.get("api_base_url"), "has_refresh_token": bool(refresh_token), - } - - -def _spotify_interactive_setup(redirect_uri_hint: str) -> str: - """Walk the user through creating a Spotify developer app; persist the client_id to ~/.hermes/.env.""" - from hermes_cli.auth import _is_remote_session - from hermes_cli.config import save_env_value - print( - f"\n{'=' * 70}\nSpotify first-time setup\n{'=' * 70}\n\n" - "Spotify requires every user to register their own lightweight\n" - "developer app. This takes about two minutes and only has to be\n" - "done once per machine.\n\n" - f"Full guide: {SPOTIFY_DOCS_URL}\n\n" - "Steps:\n" - f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...\n" - " 2. Click 'Create app' and fill in:\n" - " App name: anything (e.g. hermes-agent)\n" - " Description: anything\n" - f" Redirect URI: {redirect_uri_hint}\n" - " API/SDK: Web API\n" - " 3. Agree to the terms, click Save.\n" - " 4. Open the app's Settings page and copy the Client ID.\n" - " 5. Paste it below.\n" - ) - - if not _is_remote_session(): - try: - webbrowser.open(SPOTIFY_DASHBOARD_URL) - except Exception: - pass - - from hermes_cli.cli_output import line_input - try: - raw = line_input("Spotify Client ID: ").strip() - except (EOFError, KeyboardInterrupt): - print() - raise SystemExit("Spotify setup cancelled.") - - if not raw: - print(f"\nNo Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.") - raise SystemExit("Spotify setup cancelled: empty Client ID.") - - # Persist so later runs skip the wizard; only pin a NON-default redirect URI. - save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw) - if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI: - save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint) - - print("\nSaved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env\n") - return raw - - -def login_spotify_command(args) -> None: - from hermes_cli.auth import _auth_store_lock, _can_open_graphical_browser, _is_remote_session, _load_auth_store, _print_loopback_ssh_hint, _save_auth_store, _store_provider_state, get_provider_auth_state - existing_state = get_provider_auth_state("spotify") or {} - - # No client_id anywhere -> wizard instead of "HERMES_SPOTIFY_CLIENT_ID is required". - try: - client_id = _spotify_client_id(getattr(args, "client_id", None), existing_state) - except AuthError as exc: - if getattr(exc, "code", "") != "spotify_client_id_missing": - raise - client_id = _spotify_interactive_setup( - redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI, - ) - - redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state) - scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope")) - accounts_base_url = _spotify_accounts_base_url(existing_state) - api_base_url = _spotify_api_base_url(existing_state) - open_browser = not getattr(args, "no_browser", False) - - code_verifier = _pkce_code_verifier() - state_nonce = uuid.uuid4().hex - authorize_url = _spotify_build_authorize_url( - client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state_nonce, - code_challenge=_pkce_code_challenge(code_verifier), accounts_base_url=accounts_base_url, - ) - - print( - f"Starting Spotify PKCE login...\nClient ID: {client_id}\nRedirect URI: {redirect_uri}\n" - "Make sure this redirect URI is allow-listed in your Spotify app settings.\n\n" - f"Open this URL to authorize Hermes:\n{authorize_url}\n\nFull setup guide: {SPOTIFY_DOCS_URL}\n" - ) - - _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) - - if open_browser and not _is_remote_session() and _can_open_graphical_browser(): - try: - opened = webbrowser.open(authorize_url) - except Exception: - opened = False - print( - "Browser opened for Spotify authorization." if opened - else "Could not open the browser automatically; use the URL above." - ) - - callback = _spotify_wait_for_callback(redirect_uri, timeout_seconds=float(getattr(args, "timeout", None) or 180.0)) - if callback.get("error"): - raise SystemExit(f"Spotify authorization failed: {callback.get('error_description') or callback['error']}") - if callback.get("state") != state_nonce: - raise SystemExit("Spotify authorization failed: state mismatch.") - - token_payload = _spotify_token_post( - accounts_base_url, - { - "client_id": client_id, "grant_type": "authorization_code", - "code": str(callback.get("code") or ""), "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - }, - timeout_seconds=float(getattr(args, "timeout", None) or 20.0), - what="token exchange", failed_code="spotify_token_exchange_failed", - invalid_code="spotify_token_exchange_invalid", - invalid_message="Spotify token response did not include an access_token.", - ) - spotify_state = _spotify_token_payload_to_state( - token_payload, client_id=client_id, redirect_uri=redirect_uri, requested_scope=scope, - accounts_base_url=accounts_base_url, api_base_url=api_base_url, - ) - - with _auth_store_lock(): - auth_store = _load_auth_store() - _store_provider_state(auth_store, "spotify", spotify_state, set_active=False) - saved_to = _save_auth_store(auth_store) - - print( - f"Spotify login successful!\n Auth state: {saved_to}\n" - f" Provider state saved under providers.spotify\n Docs: {SPOTIFY_DOCS_URL}" - ) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 179b9e13a2..3773321568 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -292,8 +292,7 @@ _CLI_FAMILIES: dict[str, tuple[_CliSurface, str]] = { "memory": (_sub("memory", "build_memory_parser", "cmd_memory"), "status, *off, *reset"), "auth": ( _sub("auth", "build_auth_parser", "cmd_auth"), - "list, status, *reset, *priority, *refresh, *add, *remove, *logout, spotify status, *spotify login, " - "*spotify logout"), + "list, status, *reset, *priority, *refresh, *add, *remove, *logout"), "pairing": ( _sub("pairing", "build_pairing_parser", "cmd_pairing"), "list, *approve, *revoke, *clear-pending"), diff --git a/hermes_cli/left_core_migration.py b/hermes_cli/left_core_migration.py index 790140093f..1ca831d87b 100644 --- a/hermes_cli/left_core_migration.py +++ b/hermes_cli/left_core_migration.py @@ -97,6 +97,22 @@ def homeassistant_in_use(home: Path, *, process_env: bool = False) -> bool: return _toolset_listed(_read_config(home), frozenset({"homeassistant", "hermes-homeassistant"})) +def spotify_in_use(home: Path, *, process_env: bool = False) -> bool: + """What made core's Spotify tools usable for *home*: a login stored in its ``auth.json`` + (``providers.spotify``, written by ``hermes auth spotify``) or the ``spotify`` toolset selected + for a platform. The tools were opt-in and login-gated, so a client id alone is not use.""" + import json + try: + store = json.loads((home / "auth.json").read_text(encoding="utf-8-sig")) + except (OSError, ValueError): # missing/unreadable/invalid JSON: no login we can see + store = {} + providers = store.get("providers") if isinstance(store, dict) else None + state = providers.get("spotify") if isinstance(providers, dict) else None + if isinstance(state, dict) and (state.get("access_token") or state.get("refresh_token")): + return True + return _toolset_listed(_read_config(home), frozenset({"spotify"})) + + @dataclass(frozen=True) class LeftCoreFeature: plugin: str # catalog entry name == installed plugin name @@ -124,6 +140,9 @@ class LeftCoreFeature: # prefixes it shared with the feature's tools (stripped only when the source runs the adapter). enable_env: tuple[str, ...] = () channel_env_prefixes: tuple[str, ...] = () + # Top-level command the plugin registers in place of core's ``hermes auth `` login + # (``hermes login|status|logout``); :func:`moved_command_hint` points the old spelling at it. + cli: str = "" LEFT_CORE: tuple[LeftCoreFeature, ...] = ( @@ -134,6 +153,12 @@ LEFT_CORE: tuple[LeftCoreFeature, ...] = ( toolsets=("homeassistant",), off_platforms=("acp", "webhook"), platform="homeassistant", enable_env=("HASS_TOKEN",), channel_env_prefixes=("HASS_",), ), + LeftCoreFeature( + plugin="spotify", label="Spotify", in_use=spotify_in_use, + unchanged="your Spotify login, the spotify toolset and the spotify_* tools are unchanged; " + "`hermes auth spotify` is now `hermes spotify login`", + cli="spotify", + ), ) def platform_install_hint(platform: str) -> str: @@ -147,6 +172,27 @@ def platform_install_hint(platform: str) -> str: f"`{_install_command(feature.plugin, Path(get_hermes_home()))}`") +def moved_command_hint(prog: str, value: str, action: str = "") -> str: + """For a command a left-core plugin took over: ``hermes auth [] `` (core's old login + spelling) or ``hermes `` while the plugin is not loaded, the line saying where it went and + how to get it for the active home, else ``""``.""" + from hermes_constants import get_hermes_home + feature = next((f for f in LEFT_CORE if f.cli and f.cli == value), None) + if feature is None or prog not in {"hermes", "hermes auth"}: + return "" + home = Path(get_hermes_home()) + install = _install_command(feature.plugin, home) + if not plugin_present(feature.plugin, home): + get = f" Install it with: `{install}`." + else: + get = f" Enable it with: `{install.replace(' install ', ' enable ')}`." if prog == "hermes" else "" + if prog == "hermes auth": + old = " ".join(p for p in ("hermes auth", action, value) if p) + return (f"{feature.label} moved out of core into the '{feature.plugin}' plugin: `{old}` is now " + f"`hermes {value} {action or 'login'}`.{get}") + return f"{feature.label} moved out of core into the '{feature.plugin}' plugin, which is not loaded.{get}" + + _attempted: set[str] = set() _undelivered: dict[str, list[str]] = {} diff --git a/hermes_cli/observability/schemas/hermes.shared_metrics.v3.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v3.schema.json index 01865d1616..c8bfebf980 100644 --- a/hermes_cli/observability/schemas/hermes.shared_metrics.v3.schema.json +++ b/hermes_cli/observability/schemas/hermes.shared_metrics.v3.schema.json @@ -3274,7 +3274,6 @@ "search", "session_search", "skills", - "spotify", "start_chat", "terminal", "todo", @@ -4286,13 +4285,6 @@ "skill_manage", "skill_view", "skills_list", - "spotify_albums", - "spotify_devices", - "spotify_library", - "spotify_playback", - "spotify_playlists", - "spotify_queue", - "spotify_search", "terminal", "text_to_speech", "todo_list", diff --git a/hermes_cli/observability/schemas/hermes.shared_metrics.v4.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v4.schema.json index d773eed751..622b208b05 100644 --- a/hermes_cli/observability/schemas/hermes.shared_metrics.v4.schema.json +++ b/hermes_cli/observability/schemas/hermes.shared_metrics.v4.schema.json @@ -3637,7 +3637,6 @@ "session_search", "setup", "skills", - "spotify", "start_chat", "terminal", "todo", @@ -4644,13 +4643,6 @@ "skill_manage", "skill_view", "skills_list", - "spotify_albums", - "spotify_devices", - "spotify_library", - "spotify_playback", - "spotify_playlists", - "spotify_queue", - "spotify_search", "terminal", "text_to_speech", "todo_list", diff --git a/hermes_cli/setup_summary.py b/hermes_cli/setup_summary.py index c3d18fbaf4..596a820904 100644 --- a/hermes_cli/setup_summary.py +++ b/hermes_cli/setup_summary.py @@ -167,18 +167,6 @@ def _modal_row(config, feats): return None -def _spotify_row(config, feats): - # OAuth via hermes auth spotify — check auth.json, not env vars - try: - from hermes_cli.auth import get_provider_auth_state - state = get_provider_auth_state("spotify") or {} - if state.get("access_token") or state.get("refresh_token"): - return ("Spotify (PKCE OAuth)", True, None) - except Exception: - pass - return None - - def _skills_hub_row(config, feats): ok = bool(_setup.get_env_value("GITHUB_TOKEN")) return ("Skills Hub (GitHub)", ok, None if ok else "GITHUB_TOKEN") @@ -192,7 +180,7 @@ def _always_on_rows(config, feats): _TOOL_ROW_BUILDERS = ( _vision_row, _web_row, _browser_row, _image_gen_row, _video_gen_row, _tts_row, _stt_row, - _modal_row, _spotify_row, _skills_hub_row, _always_on_rows) + _modal_row, _skills_hub_row, _always_on_rows) def _print_cmd_rows(rows): diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index f53097a879..22574cd2f8 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -69,18 +69,4 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_upgrade.add_argument( "--no-browser", action="store_true", help="Do not auto-open a browser for sign-in") auth_upgrade.add_argument("--timeout", type=float, help="Network timeout in seconds") - auth_spotify = auth_subparsers.add_parser( - "spotify", help="Authenticate Hermes with Spotify via PKCE") - auth_spotify.add_argument( - "spotify_action", nargs="?", choices=["login", "status", "logout"], default="login") - auth_spotify.add_argument( - "--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)") - auth_spotify.add_argument( - "--redirect-uri", help="Allow-listed localhost redirect URI for your Spotify app") - auth_spotify.add_argument("--scope", help="Override requested Spotify scopes") - auth_spotify.add_argument( - "--no-browser", action="store_true", - help="Do not attempt to open the browser automatically") - auth_spotify.add_argument( - "--timeout", type=float, help="Callback/token exchange timeout in seconds") auth_parser.set_defaults(func=cmd_auth) diff --git a/hermes_cli/subcommands/logout.py b/hermes_cli/subcommands/logout.py index 04f00b815f..d5017dcbaf 100644 --- a/hermes_cli/subcommands/logout.py +++ b/hermes_cli/subcommands/logout.py @@ -11,6 +11,6 @@ def build_logout_parser(subparsers, *, cmd_logout: Callable) -> None: "logout", help="Clear authentication for an inference provider", description="Remove stored credentials and reset provider config") logout_parser.add_argument( - "--provider", choices=["nous", "openai-codex", "xai-oauth", "spotify"], default=None, + "--provider", choices=["nous", "openai-codex", "xai-oauth"], default=None, help="Provider to log out from (default: active provider)") logout_parser.set_defaults(func=cmd_logout) diff --git a/hermes_cli/subcommands/tools.py b/hermes_cli/subcommands/tools.py index 7baee8c3a4..467148c4f6 100644 --- a/hermes_cli/subcommands/tools.py +++ b/hermes_cli/subcommands/tools.py @@ -40,10 +40,10 @@ def build_tools_parser(subparsers, *, cmd_tools: Callable) -> None: description="Run the install/bootstrap hook a tool backend declares — the\n" "same step `hermes tools` runs after you pick a provider that\n" "needs extra dependencies (browser Chromium, Camofox, cua-driver,\n" - "KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI, Codex). Stable,\n" + "KittenTTS/Piper, ddgs, Langfuse, xAI, Codex). Stable,\n" "non-interactive target the dashboard spawns to drive backend\n" "setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n" - "piper, ddgs, spotify, langfuse, xai_grok, openai_codex.") + "piper, ddgs, langfuse, xai_grok, openai_codex.") tools_postsetup_p.add_argument( "post_setup_key", metavar="KEY", help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 272f1bd9d5..f0cae2d982 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -87,7 +87,6 @@ CONFIGURABLE_TOOLSETS = [ ("clarify", "❓ Clarifying Questions", "clarify"), ("delegation", "👥 Task Delegation", "delegate_task"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), - ("spotify", "🎵 Spotify", "playback, search, playlists, library"), ("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"), ("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"), ("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"), @@ -106,7 +105,8 @@ def gui_toolset_label(label: str) -> str: # OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist won't pre-select them). x_search -# auto-enables when xAI creds exist; its check_fn still gates the schema. +# auto-enables when xAI creds exist; its check_fn still gates the schema. ``spotify`` and ``a2a`` are plugin +# toolsets (catalog / bundled plugin) that stay opt-in once installed. _DEFAULT_OFF_TOOLSETS = {"spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a", "kanban"} # Config-only capabilities: provider setup in `hermes tools` (TOOL_CATEGORIES) but not model toolsets — zero @@ -345,10 +345,6 @@ TOOL_CATEGORIES = { post_setup="browser_use_cli"), ], }, - "spotify": { - "name": "Spotify", "icon": "🎵", - "providers": [_row("Spotify Web API", tag="PKCE OAuth — opens the setup wizard", post_setup="spotify")], - }, "computer_use": { "name": "Computer Use (macOS/Windows/Linux)", "icon": "🖱️", # Runtime backends ship for macOS, Windows, Linux (X11; Wayland via XWayland). Gaps surface via `computer-use doctor`. @@ -495,7 +491,7 @@ def _explicit_toolsets( toolset_names: list[str], explicit_known_keys: set[str], config: dict, platform: str, explicitly_configured: bool) -> set[str]: """Enabled set when the saved list names configurable/plugin keys directly (subset inference over - ``hermes-cli`` would re-enable disabled toolsets). A mixed list (``[hermes-cli, spotify]``) still expands the + ``hermes-cli`` would re-enable disabled toolsets). A mixed list (``[hermes-cli, discord]``) still expands the composite; _DEFAULT_OFF_TOOLSETS applies to that implicit expansion only.""" from toolsets import resolve_toolset, TOOLSETS @@ -524,7 +520,7 @@ def _composite_toolsets(toolset_names: list[str], platform: str, explicitly_conf def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: list[str], plugin_ts_keys: set[str]) -> set[str]: - """Plugin toolsets: on by default unless default-off (bundled spotify) or "known" for this platform + """Plugin toolsets: on by default unless default-off (catalog spotify) or "known" for this platform (``known_plugin_toolsets``, written on every save) and absent from the saved list.""" known_for_platform = set((config.get("known_plugin_toolsets", {}) or {}).get(platform, []) or []) return { diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py index 0cdfe96b01..4a0eda77a6 100644 --- a/hermes_cli/tools_config_post_setup.py +++ b/hermes_cli/tools_config_post_setup.py @@ -158,30 +158,6 @@ def _post_setup_python(spec: dict) -> None: _info_lines(*spec["on_install"], *spec["always"]) -def _post_setup_spotify() -> None: - # Full `hermes auth spotify` flow: no client_id yet → interactive wizard (persists to ~/.hermes/.env) - # then PKCE; existing app → OAuth only. - from types import SimpleNamespace - try: - from hermes_cli.auth import login_spotify_command - except Exception as exc: - _print_warning(f" Could not load Spotify auth: {exc}") - _info_lines("Run manually: hermes auth spotify") - return - _print_info(" Starting Spotify login...") - try: - login_spotify_command(SimpleNamespace( - client_id=None, redirect_uri=None, scope=None, no_browser=False, timeout=None)) - _print_success(" Spotify authenticated") - except SystemExit as exc: - # User aborted the wizard or OAuth failed — don't fail the toolset enable. - _print_warning(f" Spotify login did not complete: {exc}") - _info_lines("Run later: hermes auth spotify") - except Exception as exc: - _print_warning(f" Spotify login failed: {exc}") - _info_lines("Run manually: hermes auth spotify") - - def _post_setup_langfuse() -> None: import pm @@ -319,7 +295,6 @@ _POST_SETUP_HOOKS: dict = { "browser_use_cli": lambda: _ensure_browser_use_cli(verbose_hints=True), "camofox": _post_setup_camofox, "cua_driver": lambda: install_cua_driver(upgrade=False), - "spotify": _post_setup_spotify, "langfuse": _post_setup_langfuse, "xai_grok": _post_setup_xai_grok, "openai_codex": _post_setup_openai_codex, diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index c396adabcc..9a71c817ba 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -639,6 +639,12 @@ def _schedule_check_fn_probe(fn) -> Optional[threading.Thread]: return thread +def _plugin_login_command(name: str) -> str: + """A plugin that left core with its own login command (``hermes spotify login``), else ``hermes auth ``.""" + from hermes_cli.left_core_migration import LEFT_CORE + return next((f"hermes {f.cli} login" for f in LEFT_CORE if f.plugin == name and f.cli), f"hermes auth {name}") + + def _plugin_auth_hint(name: str, provides_tools: list) -> tuple: """``(auth_required, auth_command)`` from last-known cached tool availability only. @@ -655,7 +661,7 @@ def _plugin_auth_hint(name: str, provides_tools: list) -> tuple: if cached_result is None: _schedule_check_fn_probe(entry.check_fn) elif cached_result is False: - return True, f"hermes auth {name}" + return True, _plugin_login_command(name) except Exception: pass return False, "" diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 72e66d0e73..11d4728bea 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -26,7 +26,8 @@ command. A hook with no concrete consumer is speculative infrastructure and is r backends ship as standalone repos implementing the same `MemoryProvider` ABC, discovered through the same path, integrated via `hermes memory setup` / `post_setup()`. - **Other features that leave core** (Home Assistant platform + toolset, Oct 2026 → - `plugin-catalog/homeassistant.yaml`) keep their names/config/env and get a row in + `plugin-catalog/homeassistant.yaml`; Spotify toolset → `plugin-catalog/spotify.yaml`) keep their + names/config/env and get a row in `hermes_cli/left_core_migration.py::LEFT_CORE` (catalog plugin + read-only "this home uses it" predicate); `hermes update` and agent/gateway start install the plugin for homes that used it. Installing and converting core-era state are separate steps: the toolset-scope conversion runs @@ -34,7 +35,8 @@ command. A hook with no concrete consumer is speculative infrastructure and is r The automatic install also happens once per home and row (`_left_core_installed`, written only once the plugin dir exists): a later `hermes plugins remove` sticks, a failed install retries. Core special cases become `PlatformEntry` seams the plugin sets (`trusted_inbound`, - `display_tier`, `shared_env_prefixes`), never a name check. + `display_tier`, `shared_env_prefixes`), never a name check. A core `hermes auth ` login + becomes the plugin's own `hermes ` command (row field `cli`; the old spelling prints the new one). - **No new third-party-product plugins (June 2026).** Observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins ship as standalone plugin repos (`~/.hermes/plugins/` or pip entry point) promoted in Discord `#plugins-skills-and-skins`. Reason: diff --git a/plugins/spotify/__init__.py b/plugins/spotify/__init__.py deleted file mode 100644 index 230367c619..0000000000 --- a/plugins/spotify/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Spotify integration plugin — bundled, auto-loaded. - -Registers 7 tools into the ``spotify`` toolset. Tools stay registered (so they -appear in ``hermes tools``) but ``_check_spotify_available()`` gates dispatch -until the user has run ``hermes auth spotify``. - -Why a plugin rather than a ``tools/`` module: ``tools/`` is reserved for -foundational capabilities; third-party service integrations live under -``plugins/`` (flat ``plugins//`` for standalones, like image_gen backends), -and ``kind: backend`` bundled plugins auto-load with no ``plugins.enabled`` opt-in. -""" - -from __future__ import annotations - -from plugins.spotify import tools as _t - -_TOOLS = ( - ("spotify_playback", _t.SPOTIFY_PLAYBACK_SCHEMA, _t._handle_spotify_playback, "🎵"), - ("spotify_devices", _t.SPOTIFY_DEVICES_SCHEMA, _t._handle_spotify_devices, "🔈"), - ("spotify_queue", _t.SPOTIFY_QUEUE_SCHEMA, _t._handle_spotify_queue, "📻"), - ("spotify_search", _t.SPOTIFY_SEARCH_SCHEMA, _t._handle_spotify_search, "🔎"), - ("spotify_playlists", _t.SPOTIFY_PLAYLISTS_SCHEMA, _t._handle_spotify_playlists, "📚"), - ("spotify_albums", _t.SPOTIFY_ALBUMS_SCHEMA, _t._handle_spotify_albums, "💿"), - ("spotify_library", _t.SPOTIFY_LIBRARY_SCHEMA, _t._handle_spotify_library, "❤️"), -) - - -def register(ctx) -> None: - """Register all Spotify tools. Called once by the plugin loader.""" - for name, schema, handler, emoji in _TOOLS: - ctx.register_tool(name=name, toolset="spotify", schema=schema, handler=handler, check_fn=_t._check_spotify_available, emoji=emoji) diff --git a/plugins/spotify/client.py b/plugins/spotify/client.py deleted file mode 100644 index 979380c243..0000000000 --- a/plugins/spotify/client.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Thin Spotify Web API helper used by Hermes native tools. - -Owns auth (token refresh/401 retry), error mapping and id/URI normalization; -endpoint paths live with their tool handlers in ``tools.py``. -""" - -from __future__ import annotations - -from typing import Any, Dict, Iterable, Optional -from urllib.parse import urlparse - -import httpx - -from hermes_cli.auth import AuthError, resolve_spotify_runtime_credentials - - -class SpotifyError(RuntimeError): """Base Spotify tool error.""" -class SpotifyAuthRequiredError(SpotifyError): """Raised when the user needs to authenticate with Spotify first.""" - - -class SpotifyAPIError(SpotifyError): - """Structured Spotify API failure.""" - - def __init__(self, message: str, *, status_code: Optional[int] = None, response_body: Optional[str] = None) -> None: - super().__init__(message) - self.status_code, self.response_body, self.path = status_code, response_body, None - - -_empty_204 = lambda message: {"status_code": 204, "empty": True, "message": message} - - -class SpotifyClient: - def __init__(self) -> None: - self._runtime = self._resolve_runtime(refresh_if_expiring=True) - - def _resolve_runtime(self, *, force_refresh: bool = False, refresh_if_expiring: bool = True) -> dict[str, Any]: - try: - return resolve_spotify_runtime_credentials(force_refresh=force_refresh, refresh_if_expiring=refresh_if_expiring) - except AuthError as exc: - raise SpotifyAuthRequiredError(str(exc)) from exc - - @property - def base_url(self) -> str: - return str(self._runtime.get("base_url") or "").rstrip("/") - - def request( - self, method: str, path: str, *, params: Optional[dict[str, Any]] = None, json_body: Optional[dict[str, Any]] = None, - allow_retry_on_401: bool = True, empty_response: Optional[dict[str, Any]] = None, - ) -> Any: - response = httpx.request( - method, f"{self.base_url}{path}", - headers={"Authorization": f"Bearer {self._runtime['access_token']}", "Content-Type": "application/json"}, - params=_strip_none(params), json=_strip_none(json_body) if json_body is not None else None, timeout=30.0, - ) - if response.status_code == 401 and allow_retry_on_401: - # One forced token refresh, then retry exactly once. - self._runtime = self._resolve_runtime(force_refresh=True, refresh_if_expiring=True) - return self.request(method, path, params=params, json_body=json_body, allow_retry_on_401=False) - if response.status_code >= 400: - detail = response.text.strip() - message = _friendly_spotify_error_message( - status_code=response.status_code, detail=_extract_spotify_error_detail(response, fallback=detail), - path=path, retry_after=response.headers.get("Retry-After"), - ) - error = SpotifyAPIError(message, status_code=response.status_code, response_body=detail) - error.path = path - raise error - if response.status_code == 204 or not response.content: - return empty_response or {"success": True, "status_code": response.status_code, "empty": True} - if "application/json" in response.headers.get("content-type", ""): - return response.json() - return {"success": True, "text": response.text} - - # -- player: reads return an explanatory payload instead of a bare 204 -------- - - def get_playback_state(self, *, market: Optional[str] = None) -> Any: - return self.request("GET", "/me/player", params={"market": market}, empty_response=_empty_204( - "No active Spotify playback session was found. Open Spotify on a device and start playback, or transfer playback to an available device." - )) - - def get_currently_playing(self, *, market: Optional[str] = None) -> Any: - return self.request("GET", "/me/player/currently-playing", params={"market": market}, empty_response=_empty_204( - "Spotify is not currently playing anything. Start playback in Spotify and try again." - )) - - -def _extract_spotify_error_detail(response: httpx.Response, *, fallback: str) -> str: - """Prefer Spotify's ``{"error": {"message": ...}}`` (or ``{"error": "..."}``) body over raw text.""" - detail = fallback - try: - error_obj = response.json().get("error") - if isinstance(error_obj, dict): - detail = str(error_obj.get("message") or detail) - elif isinstance(error_obj, str): - detail = error_obj - except Exception: - pass - return detail.strip() - - -def _friendly_spotify_error_message(*, status_code: int, detail: str, path: str, retry_after: Optional[str]) -> str: - is_playback_path = path.startswith("/me/player") - if status_code == 401: - return "Spotify authentication failed or expired. Run `hermes auth spotify` again." - if status_code == 403: - if is_playback_path: - return ("Spotify rejected this playback request. Playback control usually requires a Spotify Premium account " - "and an active Spotify Connect device.") - if "scope" in detail.lower() or "permission" in detail.lower(): - return "Spotify rejected the request because the current auth scope is insufficient. Re-run `hermes auth spotify` to refresh permissions." - return "Spotify rejected the request. The account may not have permission for this action." - if status_code == 404: - return "Spotify could not find an active playback device or player session for this request." if is_playback_path else "Spotify resource not found." - if status_code == 429: - return "Spotify rate limit exceeded." + (f" Retry after {retry_after} seconds." if retry_after else "") - return detail or f"Spotify API request failed with status {status_code}." - - -_strip_none = lambda payload: {key: value for key, value in (payload or {}).items() if value is not None} - - -def _check_type(item_type: str, expected_type: Optional[str]) -> None: - if expected_type and item_type != expected_type: - raise SpotifyError(f"Expected a Spotify {expected_type}, got {item_type}.") - - -def normalize_spotify_id(value: str, expected_type: Optional[str] = None) -> str: - """Accept a bare id, ``spotify::`` URI, or open.spotify.com URL; return the id.""" - cleaned = (value or "").strip() - if not cleaned: - raise SpotifyError("Spotify id/uri/url is required.") - # (type, id) segments of a URI or URL; a bare id (or malformed ref) has no segments and passes through. - parts = cleaned.split(":")[1:] if cleaned.startswith("spotify:") else [] - if len(parts) < 2 and "open.spotify.com" in cleaned: - parts = [part for part in urlparse(cleaned).path.split("/") if part] - if len(parts) >= 2: - _check_type(parts[0], expected_type) - return parts[1] - return cleaned - - -def normalize_spotify_uri(value: str, expected_type: Optional[str] = None) -> str: - """Like normalize_spotify_id but returns a URI; bare ids need *expected_type* to become one.""" - cleaned = (value or "").strip() - if not cleaned: - raise SpotifyError("Spotify URI/url/id is required.") - if cleaned.startswith("spotify:"): - parts = cleaned.split(":") - if expected_type and len(parts) >= 3: - _check_type(parts[1], expected_type) - return cleaned - item_id = normalize_spotify_id(cleaned, expected_type) - return f"spotify:{expected_type}:{item_id}" if expected_type else cleaned - - -def normalize_spotify_uris(values: Iterable[str], expected_type: Optional[str] = None) -> list[str]: - """Normalize each value, dropping duplicates while keeping first-seen order.""" - uris = list(dict.fromkeys(normalize_spotify_uri(str(value), expected_type) for value in values)) - if not uris: - raise SpotifyError("At least one Spotify item is required.") - return uris diff --git a/plugins/spotify/plugin.yaml b/plugins/spotify/plugin.yaml deleted file mode 100644 index e9e1283e7d..0000000000 --- a/plugins/spotify/plugin.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: spotify -version: 1.0.0 -description: "Native Spotify integration — 7 tools (playback, devices, queue, search, playlists, albums, library) using Spotify Web API + PKCE OAuth. Auth via `hermes auth spotify`. Tools gate on `providers.spotify` in ~/.hermes/auth.json." -author: NousResearch -kind: backend -provides_tools: - - spotify_playback - - spotify_devices - - spotify_queue - - spotify_search - - spotify_playlists - - spotify_albums - - spotify_library diff --git a/plugins/spotify/tools.py b/plugins/spotify/tools.py deleted file mode 100644 index f284f6c66c..0000000000 --- a/plugins/spotify/tools.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Native Spotify tools for Hermes (registered via plugins/spotify). - -Each tool routes ``args["action"]`` through a dict dispatch table; every entry -has the signature ``(client, args, action) -> str`` and issues its Spotify Web -API call through ``client.request`` (auth refresh + error mapping live there). -""" - -from __future__ import annotations - -from typing import Any, Callable, Dict, List, Optional - -from hermes_cli.auth import get_auth_status -from plugins.spotify.client import ( - SpotifyClient, SpotifyError, normalize_spotify_id, normalize_spotify_uri, normalize_spotify_uris) -from tools.registry import tool_error, tool_result - -_Handler = Callable[[SpotifyClient, dict, str], str] - - -def _check_spotify_available() -> bool: - try: - return bool(get_auth_status("spotify").get("logged_in")) - except Exception: - return False - - -def _spotify_tool_error(exc: Exception) -> str: - if isinstance(exc, SpotifyError): # includes SpotifyAPIError / SpotifyAuthRequiredError - return tool_error(str(exc)) - return tool_error(f"Spotify tool failed: {type(exc).__name__}: {exc}") - - -# Inside a ``_dispatcher`` boundary ``raise SpotifyError(msg)`` renders exactly like ``return tool_error(msg)``. -def _required(value: Any, message: str) -> Any: - if value is None: - raise SpotifyError(message) - return value - - -def _nonblank(raw: Any, message: str) -> str: - value = str(raw or "").strip() - if not value: - raise SpotifyError(message) - return value - - -def _coerce_bool(raw: Any, default: bool = False) -> bool: - if isinstance(raw, bool): - return raw - if isinstance(raw, str): - cleaned = raw.strip().lower() - if cleaned in {"1", "true", "yes", "on"}: - return True - if cleaned in {"0", "false", "no", "off"}: - return False - return default - - -def _as_list(raw: Any) -> list[str]: - if raw is None: - return [] - items = raw if isinstance(raw, list) else [raw] - return [str(item).strip() for item in items if str(item).strip()] - - -_offset = lambda args: max(0, int(args.get("offset") or 0)) - - -def _limit(args: dict, default: int = 20) -> int: - """Clamp ``limit`` to Spotify's 1..50 window; non-numeric input falls back to *default*.""" - raw: Any = args.get("limit") - try: - value = int(raw) - except Exception: - value = default - return max(1, min(50, value)) - - -_ok = lambda action, result, **extra: tool_result({"success": True, "action": action, **extra, "result": result}) - - -def _dispatcher(tool_name: str, default: str, table: dict[str, _Handler], prepare: Optional[Callable[[dict], dict]] = None): - """Build a tool handler that routes ``args['action']`` through *table*. - - The client is constructed outside the error boundary (auth failures propagate - as exceptions, as they always have). *prepare* runs inside the boundary before - the action lookup, so its validation errors surface even for unknown actions. - """ - def handle(args: dict, **kw) -> str: - action = str(args.get("action") or default).strip().lower() - client = SpotifyClient() - try: - if prepare is not None: - args = prepare(args) - handler = table.get(action) - if handler is None: - return tool_error(f"Unknown {tool_name} action: {action}") - return handler(client, args, action) - except Exception as exc: - return _spotify_tool_error(exc) - handle.__name__ = handle.__qualname__ = f"_handle_{tool_name}" - return handle - - -# -- spotify_playback --------------------------------------------------------- - -# action -> (flag key reported False, fallback message) when Spotify returns 204/empty. -_EMPTY_PLAYBACK = { - "get_currently_playing": ("is_playing", "Spotify is not currently playing anything."), - "get_state": ("has_active_device", "No active Spotify playback session was found.")} - - -def _pb_read(fetch: Callable[..., Any], args: dict, action: str) -> str: - payload = fetch(market=args.get("market")) - if isinstance(payload, dict) and payload.get("empty"): - flag, fallback = _EMPTY_PLAYBACK[action] - payload = {"success": True, "action": action, flag: False, "status_code": payload.get("status_code", 204), - "message": payload.get("message") or fallback} - return tool_result(payload) - - -_CONTEXT_TYPES = (("album", "spotify:album:", "/album/"), ("playlist", "spotify:playlist:", "/playlist/"), ("artist", "spotify:artist:", "/artist/")) - - -def _pb_play(client: SpotifyClient, args: dict, action: str) -> str: - offset = args.get("offset") - payload_offset = {k: v for k, v in offset.items() if v is not None} if isinstance(offset, dict) else None - uris = normalize_spotify_uris(_as_list(args.get("uris")), "track") if args.get("uris") else None - context_uri = None - if args.get("context_uri"): - raw = str(args.get("context_uri")) - # Infer the context type so mismatches raise; unknown kinds pass through unchecked. - context_type = next((t for t, prefix, frag in _CONTEXT_TYPES if raw.startswith(prefix) or frag in raw), None) - context_uri = normalize_spotify_uri(raw, context_type) - body = {"context_uri": context_uri, "uris": uris, "offset": payload_offset, "position_ms": args.get("position_ms")} - return _ok(action, client.request("PUT", "/me/player/play", params={"device_id": args.get("device_id")}, json_body=body)) - - -def _pb_cmd(client: SpotifyClient, args: dict, action: str, method: str, path: str, **extra: Any) -> str: - """Player command whose only free argument is ``device_id`` (*extra* = fixed params).""" - return _ok(action, client.request(method, path, params={**extra, "device_id": args.get("device_id")})) - - -_pb_device_cmd = lambda method, path: (lambda c, a, act: _pb_cmd(c, a, act, method, path)) - - -def _pb_required_param(path: str, param: str, convert: Callable[[Any], Any]) -> _Handler: - """PUT *path* with ``param`` (required, converted) plus device_id.""" - return lambda c, a, act: _pb_cmd(c, a, act, "PUT", path, **{param: convert(_required(a.get(param), f"{param} is required for action='{act}'"))}) - - -def _pb_repeat_state(args: dict) -> str: - state = str(args.get("state") or "").strip().lower() - if state not in {"track", "context", "off"}: - raise SpotifyError("state must be one of: track, context, off") - return state - - -def _pb_recently_played(client: SpotifyClient, args: dict, action: str) -> str: - after, before = args.get("after"), args.get("before") - if after and before: - return tool_error("Provide only one of 'after' or 'before'") - params = {"limit": _limit(args), "after": int(after) if after is not None else None, "before": int(before) if before is not None else None} - return tool_result(client.request("GET", "/me/player/recently-played", params=params)) - - -_handle_spotify_playback = _dispatcher("spotify_playback", "get_state", { - "get_state": lambda c, a, act: _pb_read(c.get_playback_state, a, act), - "get_currently_playing": lambda c, a, act: _pb_read(c.get_currently_playing, a, act), - "play": _pb_play, - "pause": _pb_device_cmd("PUT", "/me/player/pause"), - "next": _pb_device_cmd("POST", "/me/player/next"), - "previous": _pb_device_cmd("POST", "/me/player/previous"), - "seek": _pb_required_param("/me/player/seek", "position_ms", int), - "set_repeat": lambda c, a, act: _pb_cmd(c, a, act, "PUT", "/me/player/repeat", state=_pb_repeat_state(a)), - "set_shuffle": lambda c, a, act: _pb_cmd(c, a, act, "PUT", "/me/player/shuffle", state=str(_coerce_bool(a.get("state"))).lower()), - "set_volume": _pb_required_param("/me/player/volume", "volume_percent", lambda v: max(0, min(100, int(v)))), - "recently_played": _pb_recently_played, -}) - - -# -- spotify_devices / spotify_queue / spotify_search --------------------------- - -_handle_spotify_devices = _dispatcher("spotify_devices", "list", { - "list": lambda c, a, act: tool_result(c.request("GET", "/me/player/devices")), - "transfer": lambda c, a, act: _ok(act, c.request("PUT", "/me/player", json_body={ - "device_ids": [_nonblank(a.get("device_id"), "device_id is required for action='transfer'")], "play": _coerce_bool(a.get("play")), - })), -}) - - -def _queue_add(client: SpotifyClient, args: dict, action: str) -> str: - uri = normalize_spotify_uri(str(args.get("uri") or ""), None) - return _ok(action, client.request("POST", "/me/player/queue", params={"uri": uri, "device_id": args.get("device_id")}), uri=uri) - - -_handle_spotify_queue = _dispatcher("spotify_queue", "get", { - "get": lambda c, a, act: tool_result(c.request("GET", "/me/player/queue")), "add": _queue_add}) - -_SEARCH_TYPES = {"album", "artist", "playlist", "track", "show", "episode", "audiobook"} - - -def _handle_spotify_search(args: dict, **kw) -> str: - client = SpotifyClient() - query = str(args.get("query") or "").strip() - if not query: - return tool_error("query is required") - raw_types = _as_list(args.get("types") or args.get("type") or ["track"]) - search_types = [value.lower() for value in raw_types if value.lower() in _SEARCH_TYPES] - if not search_types: - return tool_error("types must contain one or more of: album, artist, playlist, track, show, episode, audiobook") - params = {"q": query, "type": ",".join(search_types), "limit": _limit(args, 10), "offset": _offset(args), - "market": args.get("market"), "include_external": args.get("include_external")} - try: - return tool_result(client.request("GET", "/search", params=params)) - except Exception as exc: - return _spotify_tool_error(exc) - - -# -- spotify_playlists --------------------------------------------------------- - -_playlist_path = lambda args, suffix="": f"/playlists/{normalize_spotify_id(str(args.get('playlist_id') or ''), 'playlist')}{suffix}" - - -_handle_spotify_playlists = _dispatcher("spotify_playlists", "list", { - "list": lambda c, a, act: tool_result(c.request("GET", "/me/playlists", params={"limit": _limit(a), "offset": _offset(a)})), - "get": lambda c, a, act: tool_result(c.request("GET", _playlist_path(a), params={"market": a.get("market")})), - "create": lambda c, a, act: tool_result(c.request("POST", "/me/playlists", json_body={ - "name": _nonblank(a.get("name"), "name is required for action='create'"), "public": _coerce_bool(a.get("public")), - "collaborative": _coerce_bool(a.get("collaborative")), "description": a.get("description")})), - "add_items": lambda c, a, act: tool_result(c.request("POST", _playlist_path(a, "/items"), json_body={ - "uris": normalize_spotify_uris(_as_list(a.get("uris"))), "position": a.get("position")})), - "remove_items": lambda c, a, act: tool_result(c.request("DELETE", _playlist_path(a, "/items"), json_body={ - "items": [{"uri": u} for u in normalize_spotify_uris(_as_list(a.get("uris")))], "snapshot_id": a.get("snapshot_id")})), - "update_details": lambda c, a, act: tool_result(c.request("PUT", _playlist_path(a), json_body={ - "name": a.get("name"), "public": a.get("public"), "collaborative": a.get("collaborative"), "description": a.get("description")})), -}) - - -# -- spotify_albums ------------------------------------------------------------ - -_page_params = lambda args: {"limit": _limit(args), "offset": _offset(args), "market": args.get("market")} -_prepare_album = lambda args: {**args, "_path": f"/albums/{normalize_spotify_id(str(args.get('album_id') or args.get('id') or ''), 'album')}"} - - -_handle_spotify_albums = _dispatcher("spotify_albums", "get", { - "get": lambda c, a, act: tool_result(c.request("GET", a["_path"], params={"market": a.get("market")})), - "tracks": lambda c, a, act: tool_result(c.request("GET", a["_path"] + "/tracks", params=_page_params(a))), -}, prepare=_prepare_album) - - -# -- spotify_library — saved tracks + saved albums, selected by `kind` --------- - -def _lib_remove_uris(args: dict) -> str: - item_type = args["_item_type"] - ids = [normalize_spotify_id(item, item_type) for item in _as_list(args.get("ids") or args.get("items"))] - return ",".join(f"spotify:{item_type}:{i}" for i in _required(ids or None, "ids/items is required for action='remove'")) - - -_dispatch_library = _dispatcher("spotify_library", "list", { - "list": lambda c, a, act: tool_result(c.request("GET", f"/me/{a['kind']}", params=_page_params(a))), - "save": lambda c, a, act: tool_result(c.request("PUT", "/me/library", params={ - "uris": ",".join(normalize_spotify_uris(_as_list(a.get("uris") or a.get("items")), a["_item_type"]))})), - "remove": lambda c, a, act: tool_result(c.request("DELETE", "/me/library", params={"uris": _lib_remove_uris(a)})), -}) - - -def _handle_spotify_library(args: dict, **kw) -> str: - kind = str(args.get("kind") or "").strip().lower() - if kind not in {"tracks", "albums"}: - return tool_error("kind must be one of: tracks, albums") - return _dispatch_library({**args, "kind": kind, "_item_type": kind[:-1]}) # tracks->track, albums->album - - -# -- Schemas (sent to the model — byte-stable; property order matters) ---------- - -COMMON_STRING = {"type": "string"} -_INT = {"type": "integer"} -_BOOL = {"type": "boolean"} -_STR_ARRAY = {"type": "array", "items": COMMON_STRING} - - -_strs = lambda *names: dict.fromkeys(names, COMMON_STRING) -_enum = lambda *values: {"type": "string", "enum": list(values)} -_idesc = lambda text: {"type": "integer", "description": text} - - -def _schema(name: str, description: str, properties: dict, required: tuple = ("action",)) -> dict: - return {"name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": list(required)}} - - -SPOTIFY_PLAYBACK_SCHEMA = _schema("spotify_playback", "Control Spotify playback, inspect the active playback state, or fetch recently played tracks.", { - "action": _enum("get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume", "recently_played"), - **_strs("device_id", "market", "context_uri"), "uris": _STR_ARRAY, "offset": {"type": "object"}, "position_ms": _INT, - "state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]}, - "volume_percent": _INT, "limit": _idesc("For recently_played: number of tracks (max 50)"), - "after": _idesc("For recently_played: Unix ms cursor (after this timestamp)"), "before": _idesc("For recently_played: Unix ms cursor (before this timestamp)"), -}) -SPOTIFY_DEVICES_SCHEMA = _schema("spotify_devices", "List Spotify Connect devices or transfer playback to a different device.", - {"action": _enum("list", "transfer"), "device_id": COMMON_STRING, "play": _BOOL}) -SPOTIFY_QUEUE_SCHEMA = _schema("spotify_queue", "Inspect the user's Spotify queue or add an item to it.", {"action": _enum("get", "add"), **_strs("uri", "device_id")}) -SPOTIFY_SEARCH_SCHEMA = _schema( - "spotify_search", "Search the Spotify catalog for tracks, albums, artists, playlists, shows, or episodes.", - {"query": COMMON_STRING, "types": _STR_ARRAY, "type": COMMON_STRING, "limit": _INT, "offset": _INT, **_strs("market", "include_external")}, ("query",), -) -SPOTIFY_PLAYLISTS_SCHEMA = _schema("spotify_playlists", "List, inspect, create, update, and modify Spotify playlists.", { - "action": _enum("list", "get", "create", "add_items", "remove_items", "update_details"), - **_strs("playlist_id", "market"), "limit": _INT, "offset": _INT, **_strs("name", "description"), - "public": _BOOL, "collaborative": _BOOL, "uris": _STR_ARRAY, "position": _INT, "snapshot_id": COMMON_STRING}) -SPOTIFY_ALBUMS_SCHEMA = _schema("spotify_albums", "Fetch Spotify album metadata or album tracks.", - {"action": _enum("get", "tracks"), **_strs("album_id", "id", "market"), "limit": _INT, "offset": _INT}) -SPOTIFY_LIBRARY_SCHEMA = _schema("spotify_library", "List, save, or remove the user's saved Spotify tracks or albums. Use `kind` to select which.", { - "kind": {"type": "string", "enum": ["tracks", "albums"], "description": "Which library to operate on"}, - "action": _enum("list", "save", "remove"), - "limit": _INT, "offset": _INT, "market": COMMON_STRING, "uris": _STR_ARRAY, "ids": _STR_ARRAY, "items": _STR_ARRAY, -}, ("kind", "action")) diff --git a/skills/autonomous-ai-agents/hermes-agent/references/configuration.md b/skills/autonomous-ai-agents/hermes-agent/references/configuration.md index f8cb3abab9..a3bcffe307 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/configuration.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/configuration.md @@ -54,7 +54,7 @@ Full enumeration: `TOOLSETS` dict in `toolsets.py` (`_HERMES_CORE_TOOLS` is the | `kanban` | Multi-agent work-queue tools (gated to workers) | | `debugging` | Extra introspection tools (off by default) | | `safe` | Minimal low-risk toolset for locked-down sessions | -| `spotify`, `discord`, `discord_admin`, `feishu_doc`, `feishu_drive`, `yuanbao` | Service integrations (gated on their credentials). `homeassistant` (`ha_*` tools) is not built in: it comes from the `homeassistant` catalog plugin (`hermes plugins install homeassistant`) | +| `discord`, `discord_admin`, `feishu_doc`, `feishu_drive`, `yuanbao` | Service integrations (gated on their credentials). `homeassistant` (`ha_*` tools) and `spotify` (`spotify_*` tools) are not built in: they come from the `homeassistant` / `spotify` catalog plugins (`hermes plugins install `) | Tool changes take effect on `/reset` (new session) — never mid-conversation, to preserve prompt caching. diff --git a/tests/agent/test_coding_context.py b/tests/agent/test_coding_context.py index f9404f26ee..f6bb02dba4 100644 --- a/tests/agent/test_coding_context.py +++ b/tests/agent/test_coding_context.py @@ -79,7 +79,7 @@ class TestCodingSelection: def test_auto_is_prompt_only(self, tmp_path): # Default posture must never override the user's configured toolsets — # off-by-default toolsets are already off, and explicit opt-ins - # (image-gen, spotify, …) survive entering a code workspace. + # (image-gen, homeassistant, …) survive entering a code workspace. _git_init(tmp_path) cfg = {"agent": {"coding_context": "auto"}} assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 92c5b3ef22..b9d1db0110 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -376,9 +376,9 @@ class TestSubcommandCompletion: lambda: set(), ) - completions = _completions(SlashCommandCompleter(), "/tools enable spotify ") + completions = _completions(SlashCommandCompleter(), "/tools enable discord ") texts = {c.text for c in completions} - assert "spotify" not in texts + assert "discord" not in texts def _fake_gateway(self, monkeypatch, platforms): diff --git a/tests/hermes_cli/test_deferred_platform_client_tools.py b/tests/hermes_cli/test_deferred_platform_client_tools.py index e67c1a11ee..7cc257ecfd 100644 --- a/tests/hermes_cli/test_deferred_platform_client_tools.py +++ b/tests/hermes_cli/test_deferred_platform_client_tools.py @@ -189,7 +189,7 @@ class TestA2AClientToolsInCliProcess: def test_a2a_appears_in_the_hermes_tools_checklist(self): """`a2a` is in _DEFAULT_OFF_TOOLSETS, so it must be tickable. - Every other member of that set (spotify, video_gen, + Every other member of that set (video_gen, x_search, ...) renders a checkbox; a2a rendered nothing, so the documented opt-in path had nothing to tick. """ diff --git a/tests/hermes_cli/test_left_core_migration.py b/tests/hermes_cli/test_left_core_migration.py index e663c53e7f..1682f55ecf 100644 --- a/tests/hermes_cli/test_left_core_migration.py +++ b/tests/hermes_cli/test_left_core_migration.py @@ -278,3 +278,32 @@ def test_a_failed_startup_install_backs_off_and_reports_one_line(tmp_path, monke assert lookups == ["homeassistant"] and len(said) == 1 assert lcm.migrate_home(home, install=failing, say=said.append) == [] assert lookups == ["homeassistant", "homeassistant"] + + +@pytest.mark.parametrize(("auth_json", "config", "in_use"), [ + ('{"providers": {"spotify": {"refresh_token": "r"}}}', "", True), + ('{"providers": {"spotify": {"access_token": "a"}}}', "", True), + ("", "platform_toolsets:\n cli: [hermes-cli, spotify]\n", True), + ('{"providers": {"spotify": {"client_id": "only-the-app"}}}', "", False), + ('{"providers": {"nous": {"access_token": "a"}}}', "platform_toolsets:\n cli: [hermes-cli]\n", False), +]) +def test_spotify_in_use_is_a_stored_login_or_the_toolset(tmp_path, auth_json, config, in_use): + """Core's Spotify tools were opt-in and login-gated: a login in auth.json or the toolset selected is + use; an app client id alone (or another provider's login) is not.""" + home = _home(tmp_path, config=config) + if auth_json: + (home / "auth.json").write_text(auth_json, encoding="utf-8") + assert lcm.spotify_in_use(home) is in_use + + +def test_old_spotify_commands_point_at_the_plugin(tmp_path, monkeypatch): + """`hermes auth spotify` / `hermes spotify` name the new command and how to get it for this home.""" + home = _home(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + hint = lcm.moved_command_hint("hermes auth", "spotify", "status") + assert "`hermes auth status spotify` is now `hermes spotify status`" in hint + assert "plugins install spotify" in hint + assert "plugins install spotify" in lcm.moved_command_hint("hermes", "spotify") + (home / "plugins" / "spotify").mkdir(parents=True) # installed but disabled + assert "plugins enable spotify" in lcm.moved_command_hint("hermes", "spotify") + assert lcm.moved_command_hint("hermes", "honcho") == "" diff --git a/tests/hermes_cli/test_spotify_auth.py b/tests/hermes_cli/test_spotify_auth.py deleted file mode 100644 index eeebdabdc2..0000000000 --- a/tests/hermes_cli/test_spotify_auth.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from hermes_cli import auth as auth_mod -from hermes_cli import auth_spotify -from hermes_cli.auth import AuthError, resolve_spotify_runtime_credentials - - - - -def test_resolve_spotify_runtime_credentials_refreshes_without_changing_active_provider( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - with auth_mod._auth_store_lock(): - store = auth_mod._load_auth_store() - store["active_provider"] = "nous" - auth_mod._store_provider_state( - store, - "spotify", - { - "client_id": "spotify-client", - "redirect_uri": "http://127.0.0.1:43827/spotify/callback", - "api_base_url": auth_mod.DEFAULT_SPOTIFY_API_BASE_URL, - "accounts_base_url": auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, - "scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, - "access_token": "expired-token", - "refresh_token": "refresh-token", - "token_type": "Bearer", - "expires_at": "2000-01-01T00:00:00+00:00", - }, - set_active=False, - ) - auth_mod._save_auth_store(store) - - monkeypatch.setattr( - auth_mod, - "_refresh_spotify_oauth_state", - lambda state, timeout_seconds=20.0: { - **state, - "access_token": "fresh-token", - "expires_at": "2099-01-01T00:00:00+00:00", - }, - ) - monkeypatch.setattr( - auth_spotify, - "_refresh_spotify_oauth_state", - lambda state, timeout_seconds=20.0: { - **state, - "access_token": "fresh-token", - "expires_at": "2099-01-01T00:00:00+00:00", - }, - ) - - creds = auth_mod.resolve_spotify_runtime_credentials() - - assert creds["access_token"] == "fresh-token" - persisted = auth_mod.get_provider_auth_state("spotify") - assert persisted is not None - assert persisted["access_token"] == "fresh-token" - assert auth_mod.get_active_provider() == "nous" - - -def test_auth_spotify_status_command_reports_logged_in(capsys, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - auth_mod, - "get_auth_status", - lambda provider=None: { - "logged_in": True, - "auth_type": "oauth_pkce", - "client_id": "spotify-client", - "redirect_uri": "http://127.0.0.1:43827/spotify/callback", - "scope": "user-library-read", - }, - ) - - from hermes_cli.auth_commands import auth_status_command - - auth_status_command(SimpleNamespace(provider="spotify")) - output = capsys.readouterr().out - assert "spotify: logged in" in output - assert "client_id: spotify-client" in output - - - - - - -# --------------------------------------------------------------------------- -# Quarantine: terminal refresh failure clears dead tokens (#28139) -# --------------------------------------------------------------------------- - -_STALE_SPOTIFY_STATE = { - "client_id": "test-client", - "redirect_uri": "http://127.0.0.1:43827/spotify/callback", - "api_base_url": auth_mod.DEFAULT_SPOTIFY_API_BASE_URL, - "accounts_base_url": auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, - "scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, - "granted_scope": auth_mod.DEFAULT_SPOTIFY_SCOPE, - "token_type": "Bearer", - "access_token": "dead-access-token", - "refresh_token": "dead-refresh-token", - "expires_at": "2000-01-01T00:00:00+00:00", - "expires_in": 3600, - "obtained_at": "2000-01-01T00:00:00+00:00", - "auth_type": "oauth_pkce", -} - - -def _seed_spotify_state(tmp_path, state: dict) -> None: - with auth_mod._auth_store_lock(): - store = auth_mod._load_auth_store() - store["active_provider"] = "nous" - auth_mod._store_provider_state(store, "spotify", state, set_active=False) - auth_mod._save_auth_store(store) - - -def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Terminal refresh failure (relogin_required=True + refresh_token present) - must clear access_token/refresh_token/expires_* from auth.json and write a - last_auth_error marker so subsequent calls fail fast without a network retry. - Mirrors Nous / xAI-OAuth / Codex-OAuth / MiniMax quarantine pattern. - """ - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - _seed_spotify_state(tmp_path, dict(_STALE_SPOTIFY_STATE)) - - def _terminal_refresh(_state, **_kw): - raise AuthError( - "Spotify token refresh failed. Run `hermes auth spotify` again.", - provider="spotify", - code="spotify_refresh_failed", - relogin_required=True, - ) - - monkeypatch.setattr(auth_mod, "_refresh_spotify_oauth_state", _terminal_refresh) - monkeypatch.setattr(auth_spotify, "_refresh_spotify_oauth_state", _terminal_refresh) - - with pytest.raises(AuthError) as exc_info: - resolve_spotify_runtime_credentials(force_refresh=True) - - assert exc_info.value.code == "spotify_refresh_failed" - assert exc_info.value.relogin_required is True - - persisted = auth_mod.get_provider_auth_state("spotify") - assert persisted is not None - - # Dead OAuth fields must be cleared. - assert "access_token" not in persisted - assert "refresh_token" not in persisted - assert "expires_at" not in persisted - assert "expires_in" not in persisted - assert "obtained_at" not in persisted - - # Non-credential metadata must be preserved. - assert persisted["client_id"] == "test-client" - assert persisted["api_base_url"] == auth_mod.DEFAULT_SPOTIFY_API_BASE_URL - assert persisted["accounts_base_url"] == auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL - - # Structured diagnostic blob must be written. - err = persisted.get("last_auth_error") - assert isinstance(err, dict) - assert err["provider"] == "spotify" - assert err["code"] == "spotify_refresh_failed" - assert err["reason"] == "runtime_refresh_failure" - assert err["relogin_required"] is True - assert "at" in err - - # Active provider must be unchanged. - assert auth_mod.get_active_provider() == "nous" - - diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ef89ba0b24..3067b78422 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -562,9 +562,8 @@ class TestImagegenModelPicker: def test_get_effective_configurable_toolsets_dedupes_bundled_plugins(): - """Bundled plugins (plugins/spotify) share their toolset key with the - built-in CONFIGURABLE_TOOLSETS entry. The effective list must not list - them twice — otherwise `hermes tools` → "reconfigure existing" shows + """A plugin toolset whose key is also a CONFIGURABLE_TOOLSETS entry must not + be listed twice — otherwise `hermes tools` → "reconfigure existing" shows the same toolset two rows in a row. """ from hermes_cli.tools_config import _get_effective_configurable_toolsets @@ -575,12 +574,6 @@ def test_get_effective_configurable_toolsets_dedupes_bundled_plugins(): f"duplicate toolset keys in effective list: " f"{[k for k in keys if keys.count(k) > 1]}" ) - # Spotify specifically — the bug that motivated the dedupe. - spotify_rows = [t for t in all_ts if t[0] == "spotify"] - assert len(spotify_rows) == 1, spotify_rows - # Built-in label wins over the plugin label. - builtin_label = next(label for key, label, _ in CONFIGURABLE_TOOLSETS if key == "spotify") - assert spotify_rows[0][1] == builtin_label diff --git a/tests/tools/test_spotify_client.py b/tests/tools/test_spotify_client.py deleted file mode 100644 index 763438f6a3..0000000000 --- a/tests/tools/test_spotify_client.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import json - -import pytest - -from hermes_cli.auth import AuthError -from plugins.spotify import client as spotify_mod -from plugins.spotify import tools as spotify_tool - - -class _FakeResponse: - def __init__(self, status_code: int, payload: dict | None = None, *, text: str = "", headers: dict | None = None): - self.status_code = status_code - self._payload = payload - self.text = text or (json.dumps(payload) if payload is not None else "") - self.headers = headers or {"content-type": "application/json"} - self.content = self.text.encode("utf-8") if self.text else b"" - - def json(self): - if self._payload is None: - raise ValueError("no json") - return self._payload - - -class _StubSpotifyClient: - def __init__(self, payload): - self.payload = payload - - def get_currently_playing(self, *, market=None): - return self.payload - - -def test_spotify_client_retries_once_after_401(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[str] = [] - tokens = iter([ - { - "access_token": "token-1", - "base_url": "https://api.spotify.com/v1", - }, - { - "access_token": "token-2", - "base_url": "https://api.spotify.com/v1", - }, - ]) - - monkeypatch.setattr( - spotify_mod, - "resolve_spotify_runtime_credentials", - lambda **kwargs: next(tokens), - ) - - def fake_request(method, url, headers=None, params=None, json=None, timeout=None): - calls.append(headers["Authorization"]) - if len(calls) == 1: - return _FakeResponse(401, {"error": {"message": "expired token"}}) - return _FakeResponse(200, {"devices": [{"id": "dev-1"}]}) - - monkeypatch.setattr(spotify_mod.httpx, "request", fake_request) - - client = spotify_mod.SpotifyClient() - payload = client.request("GET", "/me/player/devices") - - assert payload["devices"][0]["id"] == "dev-1" - assert calls == ["Bearer token-1", "Bearer token-2"] - - -def test_normalize_spotify_uri_accepts_urls() -> None: - uri = spotify_mod.normalize_spotify_uri( - "https://open.spotify.com/track/7ouMYWpwJ422jRcDASZB7P", - "track", - ) - assert uri == "spotify:track:7ouMYWpwJ422jRcDASZB7P" - - -def test_get_currently_playing_returns_explanatory_empty_payload(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - spotify_mod, - "resolve_spotify_runtime_credentials", - lambda **kwargs: { - "access_token": "token-1", - "base_url": "https://api.spotify.com/v1", - }, - ) - - def fake_request(method, url, headers=None, params=None, json=None, timeout=None): - return _FakeResponse(204, None, text="", headers={"content-type": "application/json"}) - - monkeypatch.setattr(spotify_mod.httpx, "request", fake_request) - - client = spotify_mod.SpotifyClient() - payload = client.get_currently_playing() - - assert payload == { - "status_code": 204, - "empty": True, - "message": "Spotify is not currently playing anything. Start playback in Spotify and try again.", - } - - -def test_client_wraps_invalid_grant_as_spotify_auth_required_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SpotifyClient._resolve_runtime wraps AuthError(code=spotify_refresh_invalid_grant) into SpotifyAuthRequiredError.""" - - def _raise_invalid_grant(**kwargs): - raise AuthError( - "Spotify refresh token has expired or was revoked. Run `hermes auth spotify` again.", - provider="spotify", - code="spotify_refresh_invalid_grant", - relogin_required=True, - ) - - monkeypatch.setattr( - spotify_mod, - "resolve_spotify_runtime_credentials", - _raise_invalid_grant, - ) - with pytest.raises(spotify_mod.SpotifyAuthRequiredError, match="expired or was revoked"): - spotify_mod.SpotifyClient() diff --git a/tools/AGENTS.md b/tools/AGENTS.md index 32a0fd5352..2da053c595 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -71,7 +71,7 @@ Rules for tool code: Single `TOOLSETS` dict. Keys today: `browser, clarify, code_execution, cronjob, debugging, delegation, discord, discord_admin, feishu_doc, feishu_drive, file, image_gen, -kanban, memory, messaging, moa, rl, safe, search, session_search, skills, spotify, terminal, todo, +kanban, memory, messaging, moa, rl, safe, search, session_search, skills, terminal, todo, tts, video, vision, web, yuanbao` (don't assert the list in tests). Per-platform enable/disable via `hermes tools` (curses) or `tools..enabled/disabled` in config.yaml. `browser_exec` replaces the other browser tools when `browser.backend` is `browser-use`. diff --git a/toolsets.py b/toolsets.py index ad3eb9a958..b289d4f829 100644 --- a/toolsets.py +++ b/toolsets.py @@ -171,11 +171,6 @@ TOOLSETS = { "yuanbao": _ts("Yuanbao platform tools - group info, member queries, DM, stickers", _YUANBAO_TOOLS), "feishu_doc": _ts("Read Feishu/Lark document content", ["feishu_doc_read"]), "feishu_drive": _ts("Feishu/Lark document comment operations (list, reply, add)", _FEISHU_TOOLS[1:]), - "spotify": _ts( - "Native Spotify playback, search, playlist, album, and library tools", - ["spotify_playback", "spotify_devices", "spotify_queue", "spotify_search", - "spotify_playlists", "spotify_albums", "spotify_library"], - ), # Scenario-specific toolsets "debugging": _ts("Debugging and troubleshooting toolkit", ["terminal", "process_manage"], includes=["web", "file"]), diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 1e32549b94..4e8cfc9090 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -6,7 +6,7 @@ description: "How to complete browser-based OAuth (Spotify, MCP servers) when He # OAuth over SSH / Remote Hosts -Some Hermes providers — **Spotify** and **remote MCP servers** (Linear, Sentry, Atlassian, Asana, Figma, …) — use a *loopback redirect* OAuth flow. The auth server redirects your browser to `http://127.0.0.1:/callback` so a tiny HTTP listener started by Hermes can grab the authorization code. +Some Hermes providers — **Spotify** (the `spotify` catalog plugin) and **remote MCP servers** (Linear, Sentry, Atlassian, Asana, Figma, …) — use a *loopback redirect* OAuth flow. The auth server redirects your browser to `http://127.0.0.1:/callback` so a tiny HTTP listener started by Hermes can grab the authorization code. This works perfectly when Hermes and your browser are on the same machine. It breaks the moment they aren't: your laptop's browser tries to reach `127.0.0.1` on **your laptop**, but the listener is bound to `127.0.0.1` on **the remote server**. @@ -21,7 +21,7 @@ The fix is a one-line SSH local-forward. For MCP servers on an interactive termi ssh -N -L 43827:127.0.0.1:43827 user@remote-host # In your existing SSH session on the remote machine: -hermes auth spotify --no-browser +hermes spotify login --no-browser # → Hermes prints an authorize URL. Open it in a browser on your laptop. # → Your browser redirects to 127.0.0.1:43827/callback, the tunnel forwards # the request to the remote listener, login completes. @@ -101,7 +101,7 @@ ssh -N -L 43827:127.0.0.1:43827 user@remote-host ```bash ssh user@remote-host -hermes auth spotify --no-browser +hermes spotify login --no-browser ``` Hermes detects the SSH session, skips the browser auto-open, and prints an authorize URL plus a `Waiting for callback on http://127.0.0.1:/callback` line. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 02fbc0a212..d96a269e09 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -691,10 +691,9 @@ hermes auth reset openrouter 2 # Clear the cooldown on hermes auth refresh openai-codex work # Refresh one OAuth credential and clear its cooldown hermes auth status anthropic # Show auth status for a provider hermes auth logout anthropic # Log out and clear stored auth state -hermes auth spotify # Authenticate Hermes with Spotify via PKCE ``` -Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `refresh`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard. +Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `refresh`, `status`, `logout`. Spotify login moved to the `spotify` catalog plugin's `hermes spotify login`. When called with no subcommand, launches the interactive management wizard. ## `hermes usage` diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 6c157c7535..3b09094854 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -8,7 +8,7 @@ description: "Authoritative reference for Hermes built-in tools, grouped by tool This page documents Hermes' built-in tools, grouped by toolset. Availability varies by platform, credentials, and enabled toolsets. -**Quick counts (current registry):** ~100 tools — 10 browser tools (core) + 2 CDP-gated browser tools + 5 browser-vault tools + `browser_exec`, 4 file tools, 2 terminal tools (`terminal`, `process_manage`), 11 desktop-GUI tools (`read_terminal`, `close_terminal`, `desktop_preview`, `drive_preview`, `annotate_preview`, `read_window_below`, `focus_pane`, `react_to_message`, `gui_tour`, `show_tip`, `apply_layout` — desktop-app sessions only), 2 web tools, 5 Feishu tools, 7 Spotify tools (registered by the bundled `spotify` plugin), 5 Yuanbao tools, 14 kanban tools (registered when the kanban dispatcher spawns the agent), 1 project tool (`desktop_project`; desktop/GUI sessions), 2 Discord tools, 3 video tools (`video_generate`, `xai_video_edit`, `xai_video_extend`), and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob_manage`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `vision_analyze`, `video_analyze`, `todo_list`, `computer_use`, `x_search`). +**Quick counts (current registry):** ~100 tools — 10 browser tools (core) + 2 CDP-gated browser tools + 5 browser-vault tools + `browser_exec`, 4 file tools, 2 terminal tools (`terminal`, `process_manage`), 11 desktop-GUI tools (`read_terminal`, `close_terminal`, `desktop_preview`, `drive_preview`, `annotate_preview`, `read_window_below`, `focus_pane`, `react_to_message`, `gui_tour`, `show_tip`, `apply_layout` — desktop-app sessions only), 2 web tools, 5 Feishu tools, 5 Yuanbao tools, 14 kanban tools (registered when the kanban dispatcher spawns the agent), 1 project tool (`desktop_project`; desktop/GUI sessions), 2 Discord tools, 3 video tools (`video_generate`, `xai_video_edit`, `xai_video_extend`), and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob_manage`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `vision_analyze`, `video_analyze`, `todo_list`, `computer_use`, `x_search`). :::tip MCP Tools In addition to built-in tools, Hermes can load tools dynamically from MCP servers. MCP tools appear with the prefix `mcp____` (e.g., `mcp__github__create_issue` for the `github` MCP server). See [MCP Integration](../user-guide/features/mcp.md) for configuration. @@ -130,6 +130,8 @@ For local files, a full unredacted read (including all pages of the same file ve **Honcho tools** (`honcho_profile`, `honcho_search`, `honcho_context`, `honcho_reasoning`, `honcho_conclude`) are no longer built-in. They are available via the Honcho memory provider plugin from the plugin catalog (`hermes plugins install honcho`). See [Memory Providers](../user-guide/features/memory-providers.md) for installation and usage. **Home Assistant tools** (`ha_list_entities`, `ha_get_state`, `ha_list_services`, `ha_call_service`, toolset `homeassistant`) are no longer built-in. They come from the `homeassistant` catalog plugin (`hermes plugins install homeassistant`). See [Home Assistant](../user-guide/messaging/homeassistant.md). + +**Spotify tools** (`spotify_playback`, `spotify_devices`, `spotify_queue`, `spotify_search`, `spotify_playlists`, `spotify_albums`, `spotify_library`, toolset `spotify`) are no longer built-in. They come from the `spotify` catalog plugin (`hermes plugins install spotify`, then `hermes spotify login`). See [Spotify](../user-guide/features/spotify.md). ::: ## `image_gen` toolset @@ -388,20 +390,6 @@ Registered on the `hermes-discord` platform toolset. Moderation actions require |------|-------------|----------------------| | `discord_admin` | Manage a Discord server via the REST API: list guilds/channels/roles, create/edit/delete channels, manage role grants, timeouts, kicks, and bans. | `DISCORD_BOT_TOKEN` + bot permissions | -## `spotify` toolset - -Registered by the bundled `spotify` plugin. Requires an OAuth token — run `hermes auth spotify` once to authorize. - -| Tool | Description | Requires environment | -|------|-------------|----------------------| -| `spotify_playback` | Control Spotify playback, inspect the active playback state, or fetch recently played tracks. | Spotify OAuth | -| `spotify_devices` | List Spotify Connect devices or transfer playback to a different device. | Spotify OAuth | -| `spotify_queue` | Inspect the user's Spotify queue or add an item to it. | Spotify OAuth | -| `spotify_search` | Search the Spotify catalog for tracks, albums, artists, playlists, shows, or episodes. | Spotify OAuth | -| `spotify_playlists` | List, inspect, create, update, and modify Spotify playlists. | Spotify OAuth | -| `spotify_albums` | Fetch Spotify album metadata or album tracks. | Spotify OAuth | -| `spotify_library` | List, save, or remove the user's saved Spotify tracks or albums. | Spotify OAuth | - ## `hermes-yuanbao` toolset Registered only on the `hermes-yuanbao` platform toolset. Yuanbao is Tencent's chat app; these tools drive its DM/group/sticker APIs. diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index adaa21208b..198c1d86ab 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -45,7 +45,7 @@ Or in-session: ``` /tools list /tools disable browser -/tools enable spotify +/tools enable discord ``` ## Core Toolsets @@ -80,7 +80,6 @@ Or in-session: | `setup` | `setup_choose` | Onboarding-only surface of the desktop setup profile: ask the user one question or picker card at a time. The setup profile's own config enables it (`platform_toolsets.cli`), and only desktop sessions get its tools. `all` does not include it. Not listed by `hermes tools`. | | `start_chat` | `start_chat` | Start a new desktop chat in an existing profile (this chat's own profile when none is named) and send it its first message, so the task runs there in view of the user. Every call opens another chat. A profile's own config enables it (`platform_toolsets.cli`), and only desktop sessions get its tool. `all` does not include it. Not listed by `hermes tools`. Subagents never get it. | | `skills` | `skill_manage`, `skill_view`, `skills_list` | Skill CRUD and browsing. | -| `spotify` | `spotify_albums`, `spotify_devices`, `spotify_library`, `spotify_playback`, `spotify_playlists`, `spotify_queue`, `spotify_search` | Native Spotify control (playback, queue, search, playlists, albums, library). Registered by the bundled `spotify` plugin. | | `terminal` | `process_manage`, `terminal` | Shell command execution and background process management. | | `todo` | `todo_list` | Task list management within a session. | | `tts` | `text_to_speech` | Text-to-speech audio generation. | diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index e7d7bd39ca..e0d0b776ac 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -59,7 +59,6 @@ The repo ships these bundled plugins under `plugins/`. All are opt-in — enable | `security-guidance` | hooks | Pattern-match dangerous code on `write_file`/`patch` and append a security warning (or block) — 25 rules (Apache-2.0 fork of Anthropic's `claude-plugins-official` patterns) | | `observability/langfuse` | hooks | Trace turns / LLM calls / tools to [Langfuse](https://langfuse.com) | | `teams_pipeline` | standalone | Microsoft Teams meeting pipeline — Graph-backed, transcript-first meeting summaries | -| `spotify` | backend (7 tools) | Native Spotify playback, queue, search, playlists, albums, library | | `google_meet` | standalone | Join Meet calls, live-caption transcription, optional realtime duplex audio | | `image_gen/openai` | image backend | OpenAI GPT Image 2 and 2.5 Flare/Sunburst generation and editing (API key) | | `image_gen/openai-codex` | image backend | OpenAI image generation via Codex OAuth | diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md index 1a2b628293..a1b66e7ec6 100644 --- a/website/docs/user-guide/features/spotify.md +++ b/website/docs/user-guide/features/spotify.md @@ -1,48 +1,44 @@ # Spotify -Hermes can control Spotify directly — playback, queue, search, playlists, saved tracks/albums, and listening history — using Spotify's official Web API with PKCE OAuth. Tokens are stored in `~/.hermes/auth.json` and refreshed automatically on 401; you only log in once per machine (refresh tokens expire after ~6 months; re-run `hermes auth spotify` when they do). +Hermes can control Spotify — playback, queue, search, playlists, saved tracks/albums, and listening history — through the official **`spotify` plugin** from the [plugin catalog](./plugins.md). It uses Spotify's Web API with PKCE OAuth. The plugin is maintained by Nous Research in [NousResearch/hermes-spotify](https://github.com/NousResearch/hermes-spotify) and is not part of Hermes core. Tokens are stored in `~/.hermes/auth.json` and refreshed automatically on 401; you only log in once per machine (refresh tokens expire after ~6 months; re-run `hermes spotify login` when they do). -Unlike Hermes' built-in OAuth integrations (Google, GitHub Copilot, Codex), Spotify requires every user to register their own lightweight developer app. Spotify does not let third parties ship a public OAuth app that anyone can use. It takes about two minutes and `hermes auth spotify` walks you through it. +Unlike Hermes' built-in OAuth integrations, Spotify requires every user to register their own lightweight developer app. Spotify does not let third parties ship a public OAuth app that anyone can use. It takes about two minutes and `hermes spotify login` walks you through it. + +## Install + +```bash +hermes plugins install spotify +``` + +Plugins are installed per profile. To use Spotify in another profile, install it there too (`hermes -p plugins install spotify`). + +:::info Upgrading from a release that bundled Spotify +Nothing to do. Every profile that was already using Spotify (a Spotify login in its `auth.json`, or the `spotify` toolset listed in `platform_toolsets`) gets the plugin installed automatically from the catalog by `hermes update`, or the first time the profile starts if that step could not run (this honours `security.allow_lazy_installs`). Your login, the `spotify` toolset and the tool names carry over unchanged. The one visible change: `hermes auth spotify` is now `hermes spotify login` (and `hermes auth status spotify` / `hermes auth logout spotify` are `hermes spotify status` / `hermes spotify logout`); the old spelling prints the new one. If you later remove the plugin (`hermes plugins remove spotify`), it stays removed. +::: ## Prerequisites - A Spotify account. **Free** works for search, playlist, library, and activity tools. **Premium** is required for playback control (play, pause, skip, seek, volume, queue add, transfer). -- Hermes Agent installed and running. +- Hermes Agent installed and running, with the `spotify` plugin installed. - For playback tools: an **active Spotify Connect device** — the Spotify app must be open on at least one device (phone, desktop, web player, speaker) so the Web API has something to control. If nothing is active you'll get a `403 Forbidden` with a "no active device" message; open Spotify on any device and retry. ## Setup -### One-shot: `hermes tools` or first-run setup +### 1. Enable the toolset -The fastest path. Run: +The plugin's `spotify` toolset is opt-in, so users who don't want it don't ship extra tool schemas on every API call. Turn it on in `hermes tools` (toggle `🔌 Spotify`, then save) or with: ```bash -hermes tools +hermes tools enable spotify ``` -Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. The same toggle is also available during the first-run `hermes setup` / `hermes setup tools` flow. Spotify stays opt-in, so enabling it there runs the same provider-aware configuration as `hermes tools`. - -Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass. - -If you prefer to do the steps separately (or you're re-authing later), use the two-step flow below. - -### Two-step flow - -#### 1. Enable the toolset +### 2. Log in ```bash -hermes tools +hermes spotify login ``` -Toggle `🎵 Spotify` on, save, and when the inline wizard opens, dismiss it (Ctrl+C). The toolset stays on; only the auth step is deferred. - -#### 2. Run the login wizard - -```bash -hermes auth spotify -``` - -The 7 Spotify tools only appear in the agent's toolset after step 1 — they're off by default so users who don't want them don't ship extra tool schemas on every API call. +The 7 Spotify tools only reach the agent once you're logged in. If no `HERMES_SPOTIFY_CLIENT_ID` is set, Hermes walks you through the app registration inline: @@ -81,10 +77,10 @@ For jump-box / bastion setups and other gotchas (mosh, tmux, port conflicts), se ## Verify ```bash -hermes auth status spotify +hermes spotify status ``` -Shows whether tokens are present and when the access token expires. Refresh is automatic: when any Spotify API call returns 401, the client exchanges the refresh token and retries once. Refresh tokens persist across Hermes restarts, so you only re-auth if you revoke the app in your Spotify account settings or run `hermes auth logout spotify`. +Shows whether tokens are present and when the access token expires. Refresh is automatic: when any Spotify API call returns 401, the client exchanges the refresh token and retries once. Refresh tokens persist across Hermes restarts, so you only re-auth if you revoke the app in your Spotify account settings or run `hermes spotify logout`. ## Using it @@ -222,7 +218,7 @@ Full cron reference: [Cron Jobs](./cron). ## Sign out ```bash -hermes auth logout spotify +hermes spotify logout ``` Removes tokens from `~/.hermes/auth.json`. To also clear the app config, delete `HERMES_SPOTIFY_CLIENT_ID` (and `HERMES_SPOTIFY_REDIRECT_URI` if you set it) from `~/.hermes/.env`, or run the wizard again. @@ -241,7 +237,7 @@ To revoke the app on Spotify's side, visit [Apps connected to your account](http **`429 Too Many Requests`** — Spotify's rate limit. Hermes returns a friendly error; wait a minute and retry. If this persists, you're probably running a tight loop in a script — Spotify's quota resets roughly every 30 seconds. -**`401 Unauthorized` keeps coming back** — Your refresh token was revoked (usually because you removed the app from your account, or the app was deleted). Run `hermes auth spotify` again. +**`401 Unauthorized` keeps coming back** — Your refresh token was revoked (usually because you removed the app from your account, or the app was deleted). Run `hermes spotify login` again. **Wizard doesn't open the browser** — If you're over SSH or in a container without a display, Hermes detects it and skips the auto-open. Copy the dashboard URL it prints and open it manually. @@ -250,7 +246,7 @@ To revoke the app on Spotify's side, visit [Apps connected to your account](http By default Hermes requests the scopes needed for every shipped tool. Override if you want to restrict access: ```bash -hermes auth spotify --scope "user-read-playback-state user-modify-playback-state playlist-read-private" +hermes spotify login --scope "user-read-playback-state user-modify-playback-state playlist-read-private" ``` Scope reference: [Spotify Web API scopes](https://developer.spotify.com/documentation/web-api/concepts/scopes). If you request fewer scopes than a tool needs, that tool's calls will fail with 403. @@ -258,7 +254,7 @@ Scope reference: [Spotify Web API scopes](https://developer.spotify.com/document ## Advanced: custom client ID / redirect URI ```bash -hermes auth spotify --client-id --redirect-uri http://localhost:3000/callback +hermes spotify login --client-id --redirect-uri http://localhost:3000/callback ``` Or set them permanently in `~/.hermes/.env`: diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 67553fe7e1..3e1194b841 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -53,7 +53,7 @@ hermes tools hermes tools ``` -Common toolsets include `web`, `search`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, `messaging`, `spotify`, `discord`, `discord_admin`, `debugging`, and `safe`. +Common toolsets include `web`, `search`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, `messaging`, `discord`, `discord_admin`, `debugging`, and `safe`. See [Toolsets Reference](../../reference/toolsets-reference.md) for the full set, including platform presets such as `hermes-cli`, `hermes-telegram`, and dynamic MCP toolsets like `mcp-`. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md index 63c2fd3a6e..15eb77a7ae 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md @@ -6,7 +6,7 @@ description: "当 Hermes 运行在远程机器、容器或跳板机后面时, # SSH / 远程主机上的 OAuth -部分 Hermes 提供商——**Spotify** 和 **远程 MCP 服务器**(Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:/callback`,由 Hermes 启动的小型 HTTP 监听器获取授权码。 +部分 Hermes 提供商——**Spotify**(插件目录中的 `spotify` 插件)和 **远程 MCP 服务器**(Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:/callback`,由 Hermes 启动的小型 HTTP 监听器获取授权码。 当 Hermes 和浏览器在同一台机器上时,这一切运行正常。一旦两者不在同一台机器上就会出问题:你笔记本上的浏览器试图访问**你笔记本**上的 `127.0.0.1`,但监听器绑定的是**远程服务器**上的 `127.0.0.1`。 @@ -21,7 +21,7 @@ description: "当 Hermes 运行在远程机器、容器或跳板机后面时, ssh -N -L 43827:127.0.0.1:43827 user@remote-host # 在远程机器的现有 SSH 会话中: -hermes auth add spotify --no-browser +hermes spotify login --no-browser # → Hermes 打印授权 URL,在笔记本的浏览器中打开。 # → 浏览器重定向到 127.0.0.1:43827/callback,隧道转发到远程监听器,登录完成。 ``` @@ -60,7 +60,7 @@ ssh -N -L 43827:127.0.0.1:43827 user@remote-host ```bash ssh user@remote-host -hermes auth add spotify --no-browser +hermes spotify login --no-browser ``` Hermes 检测到 SSH 会话,跳过自动打开浏览器,并打印授权 URL 以及 `Waiting for callback on http://127.0.0.1:/callback`。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 0d72fefe45..8b7521820c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -348,10 +348,9 @@ hermes auth remove openrouter 2 # 按索引删除 hermes auth reset openrouter # 清除冷却时间 hermes auth status anthropic # 显示某 provider 的认证状态 hermes auth logout anthropic # 登出并清除已存储的认证状态 -hermes auth spotify # 通过 PKCE 将 Hermes 与 Spotify 认证 ``` -子命令:`add`、`list`、`remove`、`reset`、`status`、`logout`、`spotify`。不带子命令调用时,启动交互式管理向导。 +子命令:`add`、`list`、`remove`、`reset`、`status`、`logout`。Spotify 登录已移至插件目录 `spotify` 插件的 `hermes spotify login`。不带子命令调用时,启动交互式管理向导。 ## `hermes status` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/tools-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/tools-reference.md index 97999e8834..e5dd705d48 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/tools-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/tools-reference.md @@ -8,7 +8,7 @@ description: "Hermes 内置工具权威参考,按工具集分组" 本页记录 Hermes 的内置工具,按工具集分组。可用性因平台、凭据和已启用的工具集而异。 -**当前注册表快速统计:** 约 71 个工具 —— 10 个浏览器工具(核心)+ 2 个 CDP 门控浏览器工具、4 个文件工具、2 个终端工具、2 个 Web 工具、5 个 Feishu 工具、7 个 Spotify 工具(由内置 `spotify` 插件注册)、5 个 Yuanbao 工具、9 个 kanban 工具(在 kanban 调度器生成 agent 时注册)、2 个 Discord 工具,以及若干独立工具(`memory`、`clarify`、`delegate_task`、`execute_code`、`cronjob`、`session_search`、`skill_view`/`skill_manage`/`skills_list`、`text_to_speech`、`image_generate`、`video_generate`、`vision_analyze`、`video_analyze`、`send_message`、`todo`、`computer_use`、`process`)。 +**当前注册表快速统计:** 约 71 个工具 —— 10 个浏览器工具(核心)+ 2 个 CDP 门控浏览器工具、4 个文件工具、2 个终端工具、2 个 Web 工具、5 个 Feishu 工具、5 个 Yuanbao 工具、9 个 kanban 工具(在 kanban 调度器生成 agent 时注册)、2 个 Discord 工具,以及若干独立工具(`memory`、`clarify`、`delegate_task`、`execute_code`、`cronjob`、`session_search`、`skill_view`/`skill_manage`/`skills_list`、`text_to_speech`、`image_generate`、`video_generate`、`vision_analyze`、`video_analyze`、`send_message`、`todo`、`computer_use`、`process`)。 :::tip MCP 工具 除内置工具外,Hermes 还可从 MCP 服务器动态加载工具。MCP 工具以 `mcp__` 为前缀(例如,`github` MCP 服务器的 `mcp_github_create_issue`)。配置方法见 [MCP 集成](../user-guide/features/mcp.md)。 @@ -100,6 +100,8 @@ description: "Hermes 内置工具权威参考,按工具集分组" **Honcho 工具**(`honcho_profile`、`honcho_search`、`honcho_context`、`honcho_reasoning`、`honcho_conclude`)不再是内置工具。它们由插件目录中的 Honcho 记忆提供者插件提供(`hermes plugins install honcho`)。安装和使用方法见 [Memory Providers](../user-guide/features/memory-providers.md)。 **Home Assistant 工具**(`ha_list_entities`、`ha_get_state`、`ha_list_services`、`ha_call_service`,工具集 `homeassistant`)不再是内置工具。它们由插件目录中的 `homeassistant` 插件提供(`hermes plugins install homeassistant`)。详见 [Home Assistant](../user-guide/messaging/homeassistant.md)。 + +**Spotify 工具**(`spotify_playback`、`spotify_devices`、`spotify_queue`、`spotify_search`、`spotify_playlists`、`spotify_albums`、`spotify_library`,工具集 `spotify`)不再是内置工具。它们由插件目录中的 `spotify` 插件提供(`hermes plugins install spotify`,然后运行 `hermes spotify login`)。详见 [Spotify](../user-guide/features/spotify.md)。 ::: ## `image_gen` 工具集 @@ -227,20 +229,6 @@ description: "Hermes 内置工具权威参考,按工具集分组" |------|------|----------| | `discord_admin` | 通过 REST API 管理 Discord 服务器:列出 guild/频道/角色,创建/编辑/删除频道,管理角色授予、禁言、踢出和封禁。 | `DISCORD_BOT_TOKEN` + bot 权限 | -## `spotify` 工具集 - -由内置 `spotify` 插件注册。需要 OAuth token——运行一次 `hermes spotify setup` 进行授权。 - -| 工具 | 描述 | 所需环境 | -|------|------|----------| -| `spotify_playback` | 控制 Spotify 播放、查看当前播放状态或获取最近播放的曲目。 | Spotify OAuth | -| `spotify_devices` | 列出 Spotify Connect 设备或将播放转移到其他设备。 | Spotify OAuth | -| `spotify_queue` | 查看用户的 Spotify 队列或向其添加项目。 | Spotify OAuth | -| `spotify_search` | 在 Spotify 目录中搜索曲目、专辑、艺术家、播放列表、节目或单集。 | Spotify OAuth | -| `spotify_playlists` | 列出、查看、创建、更新和修改 Spotify 播放列表。 | Spotify OAuth | -| `spotify_albums` | 获取 Spotify 专辑元数据或专辑曲目。 | Spotify OAuth | -| `spotify_library` | 列出、保存或移除用户已保存的 Spotify 曲目或专辑。 | Spotify OAuth | - ## `hermes-yuanbao` 工具集 仅在 `hermes-yuanbao` 平台工具集上注册。元宝是腾讯的聊天应用;这些工具驱动其私信/群组/表情包 API。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/toolsets-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/toolsets-reference.md index d28af9a0e3..9426047c4c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/toolsets-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/toolsets-reference.md @@ -45,7 +45,7 @@ hermes tools # curses UI to enable/disable per platfo ``` /tools list /tools disable browser -/tools enable spotify +/tools enable discord ``` ## 核心工具集 @@ -73,7 +73,6 @@ hermes tools # curses UI to enable/disable per platfo | `search` | `web_search` | 仅网页搜索(不含提取)。 | | `session_search` | `session_search` | 搜索历史会话记录。 | | `skills` | `skill_manage`, `skill_view`, `skills_list` | 技能的增删改查与浏览。 | -| `spotify` | `spotify_albums`, `spotify_devices`, `spotify_library`, `spotify_playback`, `spotify_playlists`, `spotify_queue`, `spotify_search` | 原生 Spotify 控制(播放、队列、搜索、播放列表、专辑、音乐库)。由内置 `spotify` 插件注册。 | | `terminal` | `process`, `terminal` | Shell 命令执行和后台进程管理。 | | `todo` | `todo` | 会话内任务列表管理。 | | `tts` | `text_to_speech` | 文本转语音音频生成。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md index 26c4c76e72..b6c2ee6692 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md @@ -57,7 +57,6 @@ hermes plugins disable disk-cleanup |---|---|---| | `disk-cleanup` | hook + 斜杠命令 | 自动追踪临时文件并在会话结束时清理 | | `observability/langfuse` | hook | 将轮次 / LLM 调用 / 工具追踪到 [Langfuse](https://langfuse.com) | -| `spotify` | 后端(7 个工具) | 原生 Spotify 播放、队列、搜索、播放列表、专辑、曲库 | | `google_meet` | 独立插件 | 加入 Meet 通话、实时字幕转录、可选实时双工音频 | | `image_gen/openai` | 图像后端 | OpenAI `gpt-image-2` 图像生成后端(FAL 的替代方案) | | `image_gen/openai-codex` | 图像后端 | 通过 Codex OAuth 使用 OpenAI 图像生成 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/spotify.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/spotify.md index 006194f2a5..0463f54612 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/spotify.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/spotify.md @@ -1,8 +1,20 @@ # Spotify -Hermes 可以直接控制 Spotify——播放、队列、搜索、播放列表、已保存的曲目/专辑以及收听历史——通过 Spotify 官方 Web API 配合 PKCE OAuth 实现。Token(令牌)存储在 `~/.hermes/auth.json` 中,遇到 401 时自动刷新;每台机器只需登录一次。 +Hermes 通过[插件目录](./plugins.md)中的官方 **`spotify` 插件**控制 Spotify——播放、队列、搜索、播放列表、已保存的曲目/专辑以及收听历史——基于 Spotify 官方 Web API 配合 PKCE OAuth 实现。该插件由 Nous Research 在 [NousResearch/hermes-spotify](https://github.com/NousResearch/hermes-spotify) 维护,不属于 Hermes 核心。Token(令牌)存储在 `~/.hermes/auth.json` 中,遇到 401 时自动刷新;每台机器只需登录一次。 -与 Hermes 内置的 OAuth 集成(Google、GitHub Copilot、Codex)不同,Spotify 要求每位用户自行注册一个轻量级开发者应用。Spotify 不允许第三方发布可供所有人使用的公共 OAuth 应用。整个过程大约需要两分钟,`hermes auth spotify` 会全程引导你完成。 +与 Hermes 内置的 OAuth 集成不同,Spotify 要求每位用户自行注册一个轻量级开发者应用。Spotify 不允许第三方发布可供所有人使用的公共 OAuth 应用。整个过程大约需要两分钟,`hermes spotify login` 会全程引导你完成。 + +## 安装 + +```bash +hermes plugins install spotify +``` + +插件按 profile 安装。若要在其他 profile 中使用 Spotify,请在该 profile 中同样安装(`hermes -p plugins install spotify`)。 + +:::info 从内置 Spotify 的版本升级 +无需任何操作。所有已在使用 Spotify 的 profile(`auth.json` 中存有 Spotify 登录,或 `platform_toolsets` 中列出了 `spotify` 工具集)都会由 `hermes update` 自动从插件目录安装该插件;若该步骤未能运行,则在该 profile 首次启动时安装(遵循 `security.allow_lazy_installs`)。你的登录、`spotify` 工具集和工具名称都保持不变。唯一可见的变化:`hermes auth spotify` 改为 `hermes spotify login`(`hermes auth status spotify` / `hermes auth logout spotify` 改为 `hermes spotify status` / `hermes spotify logout`);旧写法会打印新命令。若之后移除插件(`hermes plugins remove spotify`),它将保持移除状态。 +::: ## 前提条件 @@ -12,37 +24,21 @@ Hermes 可以直接控制 Spotify——播放、队列、搜索、播放列表 ## 设置 -### 一键完成:`hermes tools` 或首次运行设置 +### 1. 启用工具集 -最快捷的方式。运行: +插件的 `spotify` 工具集默认关闭,以避免不需要它的用户在每次 API 调用时额外传输工具 schema。在 `hermes tools` 中启用(选中 `🔌 Spotify` 后保存),或运行: ```bash -hermes tools +hermes tools enable spotify ``` -滚动到 `🎵 Spotify`,按空格键启用,再按 `s` 保存。同样的开关也可在首次运行 `hermes setup` / `hermes setup tools` 流程中找到。Spotify 默认为可选启用,在此处启用会触发与 `hermes tools` 相同的提供商感知配置流程。 - -Hermes 会直接进入 OAuth 流程——如果你还没有 Spotify 应用,它会内联引导你创建一个。完成后,工具集即被启用并完成认证,一步到位。 - -如果你希望分步操作(或稍后重新认证),请使用下方的两步流程。 - -### 两步流程 - -#### 1. 启用工具集 +### 2. 登录 ```bash -hermes tools +hermes spotify login ``` -启用 `🎵 Spotify`,保存,当内联向导弹出时关闭它(Ctrl+C)。工具集保持开启状态,仅跳过认证步骤。 - -#### 2. 运行登录向导 - -```bash -hermes auth spotify -``` - -7 个 Spotify 工具只有在完成第 1 步后才会出现在 agent 的工具集中——它们默认关闭,以避免不需要它们的用户在每次 API 调用时额外传输工具 schema。 +7 个 Spotify 工具只有在登录后才会提供给 agent。 若未设置 `HERMES_SPOTIFY_CLIENT_ID`,Hermes 会内联引导你完成应用注册: @@ -81,10 +77,10 @@ ssh -N -L 43827:127.0.0.1:43827 user@remote-host ## 验证 ```bash -hermes auth status spotify +hermes spotify status ``` -显示 token 是否存在以及 access token 的过期时间。刷新是自动的:当任何 Spotify API 调用返回 401 时,客户端会用 refresh token 换取新 token 并重试一次。Refresh token 在 Hermes 重启后仍然有效,只有在你的 Spotify 账号设置中撤销该应用,或运行 `hermes auth logout spotify` 后才需要重新认证。 +显示 token 是否存在以及 access token 的过期时间。刷新是自动的:当任何 Spotify API 调用返回 401 时,客户端会用 refresh token 换取新 token 并重试一次。Refresh token 在 Hermes 重启后仍然有效,只有在你的 Spotify 账号设置中撤销该应用,或运行 `hermes spotify logout` 后才需要重新认证。 ## 使用方法 @@ -222,7 +218,7 @@ hermes cron add \ ## 退出登录 ```bash -hermes auth logout spotify +hermes spotify logout ``` 从 `~/.hermes/auth.json` 中移除 token。若还需清除应用配置,请从 `~/.hermes/.env` 中删除 `HERMES_SPOTIFY_CLIENT_ID`(以及 `HERMES_SPOTIFY_REDIRECT_URI`,如果你设置了的话),或重新运行向导。 @@ -241,7 +237,7 @@ hermes auth logout spotify **`429 Too Many Requests`** — Spotify 的速率限制。Hermes 会返回友好的错误提示;等待一分钟后重试。若持续出现,你可能在脚本中运行了紧密循环——Spotify 的配额大约每 30 秒重置一次。 -**`401 Unauthorized` 持续出现** — 你的 refresh token 已被撤销(通常是因为你从账号中移除了该应用,或应用被删除)。重新运行 `hermes auth spotify`。 +**`401 Unauthorized` 持续出现** — 你的 refresh token 已被撤销(通常是因为你从账号中移除了该应用,或应用被删除)。重新运行 `hermes spotify login`。 **向导未打开浏览器** — 若你通过 SSH 连接或在没有显示器的容器中运行,Hermes 会检测到并跳过自动打开。复制它打印的 dashboard URL 并手动打开。 @@ -250,7 +246,7 @@ hermes auth logout spotify 默认情况下,Hermes 会请求所有已发布工具所需的 scope。若需限制访问权限,可覆盖默认值: ```bash -hermes auth spotify --scope "user-read-playback-state user-modify-playback-state playlist-read-private" +hermes spotify login --scope "user-read-playback-state user-modify-playback-state playlist-read-private" ``` Scope 参考:[Spotify Web API scopes](https://developer.spotify.com/documentation/web-api/concepts/scopes)。若请求的 scope 少于某个工具所需,该工具的调用将以 403 失败。 @@ -258,7 +254,7 @@ Scope 参考:[Spotify Web API scopes](https://developer.spotify.com/documentat ## 进阶:自定义 client ID / redirect URI ```bash -hermes auth spotify --client-id --redirect-uri http://localhost:3000/callback +hermes spotify login --client-id --redirect-uri http://localhost:3000/callback ``` 或在 `~/.hermes/.env` 中永久设置: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md index 42ffb2b1eb..4dd9afe4ea 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md @@ -52,7 +52,7 @@ hermes tools hermes tools ``` -常用工具集包括 `web`、`search`、`terminal`、`file`、`browser`、`vision`、`image_gen`、`moa`、`skills`、`tts`、`todo`、`memory`、`session_search`、`cronjob`、`code_execution`、`delegation`、`clarify`、`messaging`、`spotify`、`discord`、`discord_admin`、`debugging` 和 `safe`。 +常用工具集包括 `web`、`search`、`terminal`、`file`、`browser`、`vision`、`image_gen`、`moa`、`skills`、`tts`、`todo`、`memory`、`session_search`、`cronjob`、`code_execution`、`delegation`、`clarify`、`messaging`、`discord`、`discord_admin`、`debugging` 和 `safe`。 完整列表(包括 `hermes-cli`、`hermes-telegram` 等平台预设以及 `mcp-` 等动态 MCP 工具集)请参阅 [工具集参考](../../reference/toolsets-reference.md)。