Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Add official Spotify Authorization Code with PKCE for Web API requests, including automatic refresh and a secure per-profile token cache while preserving cookie-based Connect behavior

### Changed

- Refresh Go dependencies, including Kong, SweetCookie, SQLite, crypto, and formatting tools, and select Go 1.26.8 while retaining Go 1.26.7 support
Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![spogo banner](docs/assets/readme-banner.jpg)

Power CLI using web cookies. Search, control playback, manage library/playlists, and script with JSON/plain output.
Power CLI using Spotify browser cookies or official OAuth. Search, control playback, manage library/playlists, and script with JSON/plain output.

Product direction and compatibility policy: [VISION.md](VISION.md).

Expand All @@ -17,13 +17,15 @@ Product direction and compatibility policy: [VISION.md](VISION.md).
- Playlist management (create/add/remove/list)
- Device selection and status
- Browser cookie import via `sweetcookie`
- Official Spotify Authorization Code OAuth with PKCE, refresh tokens, and a secure per-profile token cache
- Explicit `--auth cookies|oauth` selection for Web API requests
- `--json` and `--plain` for scripting
- Colorized human output (respects `NO_COLOR`, `TERM=dumb`, `--no-color`)
- Engine switch: `auto` (connect → web → local Spotify.app for playback on macOS), `connect` (internal endpoints), `web` (Web API endpoints; search/info/playback fall back to connect on rate limit)

## Why Cookies?
## Cookies and OAuth

Spotify's official Web API has strict rate limits that can make it impractical for agents and automation. Browser cookies let spogo use the same internal endpoints as the Spotify web player for catalog search, item lookup, library listing, listening history, and most playback and playlist operations:
Cookie auth remains the default because Spotify's official Web API has strict rate limits that can make it impractical for agents and automation. Browser cookies let spogo use the same internal endpoints as the Spotify web player for catalog search, item lookup, library listing, listening history, and most playback and playlist operations:

- **Fewer public-API rate limits** - Most reads and playback use the same internal endpoints as open.spotify.com
- **No app registration** - No need to create a Spotify Developer app
Expand All @@ -34,6 +36,15 @@ Import your cookies once with `sweetcookie` and you're good to go (defaults to C

Some operations still require Spotify's public Web API: saving/removing library tracks or albums, following/unfollowing artists, creating playlists, artist-top-track lookups used by artist playback, and certain device transfers or playback fallbacks. Explicit `--engine web` also uses the public API. These paths can return `429`; when Spotify supplies a cooldown, spogo reports its `retry-after hint`, which can be several hours.

For a cookie-free Web API setup, spogo also supports Spotify's official Authorization Code flow with PKCE:

```bash
spogo auth oauth login --client-id YOUR_SPOTIFY_CLIENT_ID
spogo --engine web --auth oauth search track "weezer"
```

OAuth never uses a client secret. Connect and internal endpoints still require browser cookies; selecting OAuth changes the Web API token provider, not the Connect protocol.

## Install

### Homebrew
Expand Down Expand Up @@ -73,6 +84,9 @@ Global flags:
- `--language <tag>` language/locale (default `en`)
- `--device <name|id>` target device
- `--engine <auto|web|connect|applescript>` API engine (default `connect`, `applescript` is macOS-only)
- `--auth <cookies|oauth>` Web API authentication (default `cookies`)
- `--spotify-client-id <id>` public Spotify application client ID
- `--spotify-redirect-uri <uri>` registered loopback OAuth redirect URI
- `--json` / `--plain`
- `--no-color`
- `-q, --quiet` / `-v, --verbose` / `-d, --debug`
Expand All @@ -86,6 +100,7 @@ Commands:

- `completion bash|zsh|fish`
- `auth status|import|paste|clear`
- `auth oauth login|status|clear`
- `search track|album|artist|playlist|show|episode`
- `track info`, `album info`, `artist info`, `playlist info`, `show info`, `episode info`
- `play [<id|url>] [--type ...] [--shuffle]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
Expand All @@ -97,9 +112,9 @@ Commands:

Full spec: `docs/spec.md`.

## Cookies
## Authentication

`spogo` uses browser cookies (via `sweetcookie`) to fetch a web access token. Import cookies once:
Cookie auth is the default. Import cookies once:

```bash
spogo auth import --browser chrome
Expand All @@ -126,6 +141,8 @@ Non-interactive:
printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input
```

Official OAuth is available for the Web API client. Register `http://127.0.0.1:8888/callback` in a Spotify developer application, then run `spogo auth oauth login --client-id ...`. See [Auth](docs/auth.md) for scopes, storage, environment variables, and the exact Connect/OAuth interaction.

## Auto engine notes

- `auto` tries connect first, then falls back to web on unsupported features or rate limits.
Expand Down
2 changes: 1 addition & 1 deletion cmd/spogo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func run(args []string, out io.Writer, errOut io.Writer) int {
parser, err := kong.New(
command,
kong.Name("spogo"),
kong.Description("Spotify power CLI using web cookies."),
kong.Description("Spotify power CLI using browser cookies or official OAuth."),
kong.UsageOnError(),
kong.Writers(out, errOut),
kong.Vars(cli.VersionVars()),
Expand Down
36 changes: 36 additions & 0 deletions cmd/spogo/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/steipete/spogo/internal/config"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/spotify"
"github.com/steipete/sweetcookie"
)

Expand Down Expand Up @@ -193,6 +196,39 @@ func TestRunAuthStatusWithoutCookiesReturnsAuthExitCode(t *testing.T) {
}
}

func TestRunOAuthStatusCommandName(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
configPath := filepath.Join(t.TempDir(), "config.toml")
code := run([]string{"--config", configPath, "--plain", "auth", "oauth", "status"}, out, errOut)
if code != 0 {
t.Fatalf("expected 0, got %d; out=%q err=%q", code, out.String(), errOut.String())
}
}

func TestRunOAuthStatusMismatchReturnsAuthExitCode(t *testing.T) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
configPath := filepath.Join(t.TempDir(), "config.toml")
cfg := config.Default()
cfg.SetProfile("default", config.Profile{Auth: "oauth", SpotifyClientID: "configured-client"})
if err := config.Save(configPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
if err := spotify.SaveOAuthToken(config.OAuthTokenPath(configPath, "default"), spotify.OAuthToken{
AccessToken: "access",
RefreshToken: "refresh",
ExpiresAt: time.Now().Add(time.Hour),
ClientID: "other-client",
}); err != nil {
t.Fatal("save token:", err)
}
code := run([]string{"--config", configPath, "--plain", "auth", "oauth", "status"}, out, errOut)
if code != 3 {
t.Fatalf("expected 3, got %d; out=%q err=%q", code, out.String(), errOut.String())
}
}

func TestNormalizeArgsMovesNoInput(t *testing.T) {
got := normalizeArgs([]string{"auth", "paste", "--no-input", "--cookie-path", "cookies.json"})
want := []string{"--no-input", "auth", "paste", "--cookie-path", "cookies.json"}
Expand Down
11 changes: 7 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ See [Output](output.md) for the full contract.
#!/usr/bin/env bash
set -euo pipefail

# Make sure auth still works
# Make sure the configured cookie store exists
if ! spogo auth status >/dev/null 2>&1; then
echo "spogo: cookies missing or stale; re-run 'spogo auth import'" >&2
exit 3
fi

# For an OAuth-only Web API profile, inspect local OAuth state instead:
# spogo --engine web --auth oauth auth oauth status --json

# Capture the currently playing track ID
track_id=$(spogo status --json | jq -r '.item.id // empty')
if [[ -z "$track_id" ]]; then
Expand Down Expand Up @@ -89,7 +92,7 @@ spogo writes nothing to stdout that isn't useful and nothing to stderr unless so
0 4 * * * /usr/local/bin/spogo library tracks list --limit 1000 --json > "$HOME/snapshots/tracks-$(date +\%F).json" 2>&1
```

For headless servers / CI runners, copy a working cookie jar (from a machine where you ran `auth import`) into the runner's spogo config directory rather than trying to import from a browser that doesn't exist.
For headless servers / CI runners, either copy a working cookie jar (from a machine where you ran `auth import`) into the runner's spogo config directory, or provision an OAuth token cache created by `auth oauth login` for `--engine web --auth oauth`. Both files are credentials. Do not print them or commit them.

## CI

Expand Down Expand Up @@ -118,7 +121,7 @@ spogo is a good fit for AI coding agents (Claude Code, Codex, Cursor) because:

- **Self-documenting.** `spogo --help` and `spogo <subcommand> --help` describe the entire surface. The [Spec](spec.md) is short and stable.
- **Deterministic.** Stable JSON keys mean the agent's parsing doesn't drift across releases.
- **Safe-ish.** The destructive surface is small (`library tracks remove`, `playlist remove`, `auth clear`). Wrap those behind explicit confirmation in your agent prompt.
- **Safe-ish.** The destructive surface is small (`library tracks remove`, `playlist remove`, `auth clear`, `auth oauth clear`). Wrap those behind explicit confirmation in your agent prompt.

Recommended agent rules:

Expand All @@ -129,7 +132,7 @@ Recommended agent rules:

A starter system prompt fragment for an agent:

> You can use the `spogo` CLI to control Spotify. Always pass `--json` and `--no-input`. Read `spogo --help` and `spogo <cmd> --help` before invoking unfamiliar commands. Treat exit code `3` as "needs auth" — surface that to the user, don't try to recover automatically.
> You can use the `spogo` CLI to control Spotify. Always pass `--json` and `--no-input`. Read `spogo --help` and `spogo <cmd> --help` before invoking unfamiliar commands. Treat exit code `3` as "needs auth". Surface that to the user; do not launch an interactive cookie import or OAuth login automatically.

## Safety

Expand Down
Loading
Loading