refactor: move Spotify out of core into the spotify catalog plugin

The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.

The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.

Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
This commit is contained in:
Teknium committed 2026-10-08 19:00:45 -07:00
1 parent 358ec68b90
commit 71704ffb49
45 files changed
+201 -1462

No files matched your search

+9
View File
@@ -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.")
+6 -11
View File
@@ -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.<name>`` 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",
+12 -14
View File
@@ -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:
-14
View File
@@ -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")
-390
View File
@@ -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.<helper>`` 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}"
)
+1 -2
View File
@@ -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"),
+46
View File
@@ -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 <cli>`` login
# (``hermes <cli> 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 [<action>] <cli>`` (core's old login
spelling) or ``hermes <cli>`` 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]] = {}
@@ -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",
@@ -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",
+1 -13
View File
@@ -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):
-14
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)")
+4 -8
View File
@@ -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 {
-25
View File
@@ -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,
+7 -1
View File
@@ -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 <name>``."""
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, ""
+4 -2
View File
@@ -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 <name>` login
becomes the plugin's own `hermes <name>` 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:
-31
View File
@@ -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/<name>/`` 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)
-161
View File
@@ -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:<type>:<id>`` 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
-13
View File
@@ -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
-316
View File
@@ -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"))
@@ -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 <name>`) |
Tool changes take effect on `/reset` (new session) — never mid-conversation, to preserve prompt caching.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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):
@@ -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.
"""
@@ -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") == ""
-179
View File
@@ -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"
+2 -9
View File
@@ -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
-120
View File
@@ -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()
+1 -1
View File
@@ -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.<platform>.enabled/disabled` in config.yaml. `browser_exec`
replaces the other browser tools when `browser.backend` is `browser-use`.
-5
View File
@@ -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"]),
+3 -3
View File
@@ -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:<port>/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:<port>/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:<port>/callback` line.
+1 -2
View File
@@ -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`
+3 -15
View File
@@ -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__<server>__` (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.
+1 -2
View File
@@ -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. |
@@ -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 |
+27 -31
View File
@@ -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 <profile> 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 <id> --redirect-uri http://localhost:3000/callback
hermes spotify login --client-id <id> --redirect-uri http://localhost:3000/callback
```
Or set them permanently in `~/.hermes/.env`:
+1 -1
View File
@@ -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-<server>`.
@@ -6,7 +6,7 @@ description: "当 Hermes 运行在远程机器、容器或跳板机后面时,
# SSH / 远程主机上的 OAuth
部分 Hermes 提供商——**Spotify** 和 **远程 MCP 服务器**(Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:<port>/callback`,由 Hermes 启动的小型 HTTP 监听器获取授权码。
部分 Hermes 提供商——**Spotify**(插件目录中的 `spotify` 插件)和 **远程 MCP 服务器**(Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:<port>/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:<port>/callback`。
@@ -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`
@@ -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_<server>_` 为前缀(例如,`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。
@@ -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` | 文本转语音音频生成。 |
@@ -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 图像生成 |
@@ -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 <profile> 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 <id> --redirect-uri http://localhost:3000/callback
hermes spotify login --client-id <id> --redirect-uri http://localhost:3000/callback
```
或在 `~/.hermes/.env` 中永久设置:
@@ -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-<server>` 等动态 MCP 工具集)请参阅 [工具集参考](../../reference/toolsets-reference.md)。