diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6e8774bf5..0a472f2d8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -86,6 +86,8 @@ 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 DEFAULT_SPOTIFY_SCOPE = " ".join(( "user-modify-playback-state", @@ -1917,9 +1919,83 @@ def get_spotify_auth_status() -> Dict[str, Any]: } +def _spotify_interactive_setup(redirect_uri_hint: str) -> str: + """Walk the user through creating a Spotify developer app, persist the + resulting client_id to ~/.hermes/.env, and return it. + + Raises SystemExit if the user aborts or submits an empty value. + """ + from hermes_cli.config import save_env_value + + print() + print("=" * 70) + print("Spotify first-time setup") + print("=" * 70) + print() + print("Spotify requires every user to register their own lightweight") + print("developer app. This takes about two minutes and only has to be") + print("done once per machine.") + print() + print(f"Full guide: {SPOTIFY_DOCS_URL}") + print() + print("Steps:") + print(f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...") + print(" 2. Click 'Create app' and fill in:") + print(" App name: anything (e.g. hermes-agent)") + print(" Description: anything") + print(f" Redirect URI: {redirect_uri_hint}") + print(" API/SDK: Web API") + print(" 3. Agree to the terms, click Save.") + print(" 4. Open the app's Settings page and copy the Client ID.") + print(" 5. Paste it below.") + print() + + if not _is_remote_session(): + try: + webbrowser.open(SPOTIFY_DASHBOARD_URL) + except Exception: + pass + + try: + raw = input("Spotify Client ID: ").strip() + except (EOFError, KeyboardInterrupt): + print() + raise SystemExit("Spotify setup cancelled.") + + if not raw: + print() + print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.") + raise SystemExit("Spotify setup cancelled: empty Client ID.") + + # Persist so subsequent `hermes auth spotify` runs skip the wizard. + save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw) + # Only persist the redirect URI if it's non-default, to avoid pinning + # users to a value the default might later change to. + if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI: + save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint) + + print() + print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env") + print() + return raw + + def login_spotify_command(args) -> None: existing_state = get_provider_auth_state("spotify") or {} - client_id = _spotify_client_id(getattr(args, "client_id", None), existing_state) + + # Interactive wizard: if no client_id is configured anywhere, walk the + # user through creating the Spotify developer app instead of crashing + # with "HERMES_SPOTIFY_CLIENT_ID is required". + explicit_client_id = getattr(args, "client_id", None) + try: + client_id = _spotify_client_id(explicit_client_id, 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) @@ -1946,6 +2022,8 @@ def login_spotify_command(args) -> None: print("Open this URL to authorize Hermes:") print(authorize_url) print() + print(f"Full setup guide: {SPOTIFY_DOCS_URL}") + print() if open_browser and not _is_remote_session(): try: @@ -1992,6 +2070,7 @@ def login_spotify_command(args) -> None: print("Spotify login successful!") print(f" Auth state: {saved_to}") print(" Provider state saved under providers.spotify") + print(f" Docs: {SPOTIFY_DOCS_URL}") # ============================================================================= # SSH / remote session detection diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 42170604c..c18f6ab4e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -373,7 +373,7 @@ TOOL_CATEGORIES = { {"key": "HERMES_SPOTIFY_CLIENT_ID", "prompt": "Spotify app client_id", "url": "https://developer.spotify.com/dashboard"}, {"key": "HERMES_SPOTIFY_REDIRECT_URI", "prompt": "Redirect URI (must be allow-listed in your Spotify app)", - "default": "http://127.0.0.1:8888/callback"}, + "default": "http://127.0.0.1:43827/spotify/callback"}, ], }, ], diff --git a/tests/hermes_cli/test_spotify_auth.py b/tests/hermes_cli/test_spotify_auth.py index 4873d5f9d..ca9c97560 100644 --- a/tests/hermes_cli/test_spotify_auth.py +++ b/tests/hermes_cli/test_spotify_auth.py @@ -86,3 +86,53 @@ def test_auth_spotify_status_command_reports_logged_in(capsys, monkeypatch: pyte output = capsys.readouterr().out assert "spotify: logged in" in output assert "client_id: spotify-client" in output + + + +def test_spotify_interactive_setup_persists_client_id( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys, +) -> None: + """The wizard writes HERMES_SPOTIFY_CLIENT_ID to .env and returns the value.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("builtins.input", lambda prompt="": "wizard-client-123") + # Prevent actually opening the browser during tests. + monkeypatch.setattr(auth_mod, "webbrowser", SimpleNamespace(open=lambda *_a, **_k: False)) + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + + result = auth_mod._spotify_interactive_setup( + redirect_uri_hint=auth_mod.DEFAULT_SPOTIFY_REDIRECT_URI, + ) + assert result == "wizard-client-123" + + env_path = tmp_path / ".env" + assert env_path.exists() + env_text = env_path.read_text() + assert "HERMES_SPOTIFY_CLIENT_ID=wizard-client-123" in env_text + # Default redirect URI should NOT be persisted. + assert "HERMES_SPOTIFY_REDIRECT_URI" not in env_text + + # Docs URL should appear in wizard output so users can find the guide. + output = capsys.readouterr().out + assert auth_mod.SPOTIFY_DOCS_URL in output + + +def test_spotify_interactive_setup_empty_aborts( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Empty input aborts cleanly instead of persisting an empty client_id.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("builtins.input", lambda prompt="": "") + monkeypatch.setattr(auth_mod, "webbrowser", SimpleNamespace(open=lambda *_a, **_k: False)) + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + + with pytest.raises(SystemExit): + auth_mod._spotify_interactive_setup( + redirect_uri_hint=auth_mod.DEFAULT_SPOTIFY_REDIRECT_URI, + ) + + env_path = tmp_path / ".env" + if env_path.exists(): + assert "HERMES_SPOTIFY_CLIENT_ID" not in env_path.read_text() diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md new file mode 100644 index 000000000..ecadc7245 --- /dev/null +++ b/website/docs/user-guide/features/spotify.md @@ -0,0 +1,118 @@ +# 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. + +Unlike most Hermes 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. The whole thing takes about two minutes. + +## Prerequisites + +- A Spotify account (Free works for most tools; **playback control requires Premium**) +- Hermes Agent installed and running + +## Setup + +### 1. Enable the toolset + +```bash +hermes tools +``` + +Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. + +### 2. Run the login wizard + +```bash +hermes auth spotify +``` + +If you don't have a Spotify app yet, Hermes walks you through creating one: + +1. Opens the Spotify developer dashboard in your browser +2. Tells you exactly what values to paste into the Spotify form +3. Prompts you for the `Client ID` you get back +4. Saves it to `~/.hermes/.env` and continues straight into the OAuth flow + +After the Spotify consent page, tokens are saved under `providers.spotify` in `~/.hermes/auth.json` and the integration is live. + +### Creating the Spotify app (what the wizard asks for) + +When you land on the dashboard, click **Create app** and fill in: + +| Field | Value | +|-------|-------| +| App name | anything (e.g. `hermes-agent`) | +| App description | anything (e.g. `personal Hermes integration`) | +| Website | leave blank | +| Redirect URI | `http://127.0.0.1:43827/spotify/callback` | +| Which API/SDKs? | **Web API** | + +Agree to the terms, click **Save**. On the next screen click **Settings** → copy the **Client ID**. That's the only value Hermes needs (no client secret — PKCE doesn't use one). + +## Verify + +```bash +hermes auth status spotify +``` + +Shows whether tokens are present and when the access token expires. Hermes automatically refreshes on 401. + +## Using it + +Once logged in, the agent has access to 9 Spotify tools: + +| Tool | Actions | +|------|---------| +| `spotify_playback` | play, pause, skip, seek, volume, now playing, playback state | +| `spotify_devices` | list devices, transfer playback | +| `spotify_queue` | inspect queue, add tracks to queue | +| `spotify_search` | search tracks, albums, artists, playlists | +| `spotify_playlists` | list, get, create, update, add/remove tracks | +| `spotify_albums` | get album, list album tracks | +| `spotify_saved_tracks` | list, save, remove | +| `spotify_saved_albums` | list, save, remove | +| `spotify_activity` | recently played, now playing | + +The agent picks the right tool automatically. Ask it to "play some Miles Davis," "what am I listening to," "add the current track to my starred playlist," etc. + +## Sign out + +```bash +hermes auth logout spotify +``` + +Removes tokens from `~/.hermes/auth.json`. To also clear the app config, delete `HERMES_SPOTIFY_CLIENT_ID` (and optionally `HERMES_SPOTIFY_REDIRECT_URI`) from `~/.hermes/.env`. + +## Troubleshooting + +**`403 Forbidden` on playback endpoints** — Spotify requires Premium for `play`, `pause`, `skip`, and volume control. Search, playlists, and library reads work on Free. + +**`204 No Content` on `now_playing`** — nothing is currently playing; expected behavior, not an error. + +**`INVALID_CLIENT: Invalid redirect URI`** — the redirect URI registered in your Spotify app doesn't match what Hermes is using. Default is `http://127.0.0.1:43827/spotify/callback`. If you picked something else, set `HERMES_SPOTIFY_REDIRECT_URI` in `~/.hermes/.env` to match. + +**`429 Too Many Requests`** — Spotify rate limit. Hermes surfaces this as a friendly error; wait a minute and retry. + +## Advanced: custom scopes + +By default Hermes requests the scopes needed for every shipped tool. To override: + +```bash +hermes auth spotify --scope "user-read-playback-state user-modify-playback-state playlist-read-private" +``` + +See Spotify's [scope reference](https://developer.spotify.com/documentation/web-api/concepts/scopes) for available values. + +## Advanced: custom client ID / redirect URI + +```bash +hermes auth spotify --client-id --redirect-uri http://localhost:3000/callback +``` + +Or set them permanently in `~/.hermes/.env`: + +``` +HERMES_SPOTIFY_CLIENT_ID= +HERMES_SPOTIFY_REDIRECT_URI=http://localhost:3000/callback +``` + +The redirect URI must be allow-listed in your Spotify app's settings. diff --git a/website/sidebars.ts b/website/sidebars.ts index d7945a1f8..8e593f802 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -89,6 +89,7 @@ const sidebars: SidebarsConfig = { label: 'Advanced', items: [ 'user-guide/features/rl-training', + 'user-guide/features/spotify', ], }, {