This document defines Spotify-specific behavior. The policies in
cli-common
are authoritative for command naming, state, secrets, output, scriptability,
CI, release, and distribution. When this document differs, cli-common wins
and this document must be corrected.
The currently implemented surface contains exactly these commands:
sptfy initsptfy set-credentialsptfy config show|path|clearsptfy mesptfy search track <query>sptfy search album <query>sptfy search artist <query>sptfy tracks get <id-or-reference>sptfy albums get <id-or-reference>sptfy artists get <id-or-reference>sptfy albums tracks list <album-id-or-reference>sptfy artists albums list <artist-id-or-reference>sptfy library tracks listsptfy library tracks check <track-reference>...sptfy library tracks add <track-reference>...sptfy library tracks remove <track-reference>...sptfy library albums listsptfy library albums check <album-reference>...sptfy library albums add <album-reference>...sptfy library albums remove <album-reference>...sptfy playlists listsptfy playlists get <playlist-reference>sptfy playlists items list <playlist-reference>sptfy playlists items add <playlist-reference> <track-reference>...sptfy playlists items remove <playlist-reference> <zero-based-position>sptfy playlists items update <playlist-reference> <zero-based-position> --item <track-reference>
The product exclusions under Future boundaries remain directional and non-normative until promoted into command sections.
sptfy uses Spotify Authorization Code with PKCE for user authorization. The
user supplies a Spotify client ID; the CLI never accepts or stores a client
secret. The CLI requests exactly these sorted scopes: playlist-modify-private,
playlist-modify-public, playlist-read-collaborative,
playlist-read-private, user-library-modify, user-library-read, and
user-read-private. Catalog reads do not require an additional scope. A
credential missing a command's required library or playlist scope fails before
its Spotify resource request with a sptfy init --overwrite hint.
OAuth access and refresh material is stored only in a cli-common/credstore
backend under the configured credential reference. It is never stored in the
configuration file, read from a runtime Spotify-token environment variable, or
included in output and errors.
init is the interactive and scriptable PKCE setup flow. It collects
non-secret configuration, completes authorization, writes the OAuth credential,
and verifies the identity unless verification is explicitly disabled.
The setup flow follows the shared wizard contract:
- The redirect URI defaults to
http://127.0.0.1/callbackand must be allowlisted in the Spotify application. A configured redirect URI may use127.0.0.1with an explicit port. In ordinary callback mode, a portless URI binds an available loopback port under Spotify's dynamic-port exception.--auth-code-stdinstarts no listener and keeps the configured URI. The exact URI resolved for authorization is reused for token exchange.localhostand non-loopback HTTP redirects are rejected. - Every interactive input has an equivalent flag.
--non-interactiveor non-TTY stdin disables prompts.--no-browsersuppresses browser opening but still prints the authorization URL to stderr.--auth-code-stdinreads the complete redirected URL from stdin and implies--no-browser. A raw authorization code is rejected because it cannot prove the returned OAuth state.--overwritepermits replacement of an existing credential and has no other meaning.--no-verifyskips the post-authorization identity check.
set-credential is the low-level one-secret ingress path. It supports the
shared --ref, --key oauth_token, exactly one of --stdin or --from-env,
--overwrite, and --json surface. It validates the OAuth token envelope but
does not call Spotify or echo secret material.
config showreports non-secret configuration, active credential reference, backend selection/source, and OAuth credential presence.config pathreports the resolved state paths.config clearremoves credentials for the active reference while retaining reusable non-secret configuration.config clear --alladditionally removes configuration and owned cache state. It supports--dry-runand never touches other credential references.
config show, config path, and config clear support --json because they
are control-plane operations.
me is the canonical health check. It exits successfully only when
configuration is complete, Spotify is reachable, the credential is usable or
refreshable, and the current-user request succeeds.
Text output contains the stable Spotify account ID, display name when present,
Spotify user ID and URI when present, and the granted OAuth scopes. me --json
is allowed because me is a control-plane diagnostic. Failures are concise,
actionable, secret-free messages on stderr.
Resource commands emit token-efficient text, never JSON.
- Primary data goes to stdout.
- Prompts, progress, warnings, retry notices, continuation hints, and errors go to stderr.
- Lists use stable
ALL_CAPSheaders and|separators. - Empty values render as
-. - Embedded newlines, carriage returns, and the structural
|sequence are replaced inside cells so one resource remains one line. - Production resource output has no color.
JSON is supported only by these initial control-plane operations:
set-credentialconfig showconfig pathconfig clearme
Resource commands register only meaningful shared flags:
--idemits one primary Spotify ID per line and overrides every other output shape.--extendedadds less-frequent metadata such as Spotify URIs, URLs, disc and track positions, explicit status, and restrictions.--fields <csv>replaces the selected columns. Matching is case-insensitive; an unknown field fails with the valid field list.--include-artworkadds Spotify-provided image URL and dimensions without downloading image data.
--fulltext is not registered for track search because that result has no
prose field requiring truncation control.
Spotify catalog relationships are a graph, not a single parent tree. Outputs
therefore expose explicit identifiers instead of a generic parent object.
- Track rows include the associated album ID and every credited artist ID.
- Album rows include every credited artist ID.
- Parent-scoped future commands render the parent identity once above the child table instead of repeating it in every row.
- Membership and mutation commands do not make extra metadata calls solely to manufacture breadcrumbs.
These identifiers are part of the contextually rich default output so an agent can traverse track → album and track/album → artists without first widening the result.
Search accepts exactly one non-empty textual query. Spotify query syntax such
as artist:, album:, track:, year:, genre:, and isrc: is passed
through unchanged.
Flags:
-m, --max <count>sets the page size. The default and maximum are 10 because Spotify Development Mode caps each search request at 10 results.--next-page-token <token>resumes the next page. The token is opaque to callers, owned bysptfy, bound to track search, and validated before any network request.--id,--extended,--fields, and--include-artworkfollow the shared output rules above.
Default output:
ID | TRACK | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION
Multiple credited artists are comma-separated in the corresponding cells. The
duration uses m:ss, or h:mm:ss for tracks at least one hour long. Artwork is
omitted unless explicitly requested.
When another page exists, the command writes this shape to stderr:
More results available (next: <opaque-token>)
An empty result is successful and emits no fabricated row. Invalid queries, page sizes, page tokens, and field selections fail before the Spotify request.
Album search follows the track-search query, pagination, validation, and output shape rules. Its continuation tokens are bound to album search.
Default output:
ID | ALBUM | ARTIST_IDS | ARTISTS | RELEASE_DATE | TOTAL_TRACKS
--extended adds URI, URL, ALBUM_TYPE,
RELEASE_DATE_PRECISION, and RESTRICTION. --include-artwork adds
ARTWORK. Every credited artist ID and name is emitted in its corresponding
comma-separated cell.
Artist search follows the track-search query, pagination, validation, and output shape rules. Its continuation tokens are bound to artist search.
Default output:
ID | ARTIST
--extended adds URI and URL. --include-artwork adds ARTWORK.
Popularity and follower data are not requested or rendered.
Each command accepts exactly one 22-character ASCII alphanumeric Spotify ID,
matching spotify:<kind>:<id> URI, or canonical
https://open.spotify.com/<kind>/<id> URL. Other URL origins, userinfo, ports,
query strings, fragments, wrong resource kinds, and additional path components
are rejected before authentication is opened.
The commands perform one fixed-origin request for the named resource and never
follow the supplied URL. Output is text-only and starts with the stable
ID Name identity header. Remaining attributes use Key: Value pairs
separated by three spaces. Track output includes album and artist relationship
IDs; album output includes artist IDs.
--id emits only the fetched resource ID and overrides the other shape flags.
--extended, --fields, and --include-artwork follow the shared output
rules. --fields replaces attributes below the identity header; selected
identity fields are not duplicated. Artwork is URL and dimensions metadata
only and is never downloaded.
Both commands accept the same validated raw ID, URI, and URL forms as catalog
gets. They request one fixed-origin relationship page and never follow a
provider next URL. --next-page-token is bound to the relationship and
parsed parent ID and is validated before authentication.
Normal output starts with exactly one Album ID: <id> or Artist ID: <id>
line followed by the child table. --id emits only child IDs, one per line.
Album tracks default to 10 and allow --max values from 1 through 50. Their
fields are ID, TRACK, ARTIST_IDS, ARTISTS, and DURATION; --extended
adds URI, URL, DISC_NUMBER, TRACK_NUMBER, EXPLICIT, and
RESTRICTION. Parent album and artwork fields are unavailable. Artist albums
default to 10 and allow --max values from 1 through 10; they reuse album
fields, including optional artwork URL metadata.
sptfy library tracks list uses GET /me/tracks, defaults to 10, and accepts
1–50 results. Its opaque continuation token is bound to library-tracks;
provider response URLs are never followed. Normal output begins with
ADDED_AT and the established track fields. --id is pure IDs, while
--fields, --extended, and --include-artwork follow the standard
precedence.
check, add, and remove accept only raw 22-character track IDs, typed
track URIs, or canonical track URLs. The full batch is validated, normalized,
and deduplicated before authentication opens. The first reference and
first-seen order are retained. Check uses GET /me/library/contains; add uses
PUT /me/library; remove uses DELETE /me/library. Requests pass uris as a
query parameter in chunks of at most 40. Check output is
REFERENCE | ID | SAVED. Mutations emit only added<TAB>N or removed<TAB>N
after every chunk succeeds; partial failure is reported without rollback.
sptfy library albums list uses GET /me/albums, defaults to 10, and accepts
1–50 results. Its opaque continuation token is bound to library-albums;
provider response URLs are never followed. Normal output begins with
ADDED_AT, followed by ID, ALBUM, every credited ARTIST_IDS and
ARTISTS value, RELEASE_DATE, and TOTAL_TRACKS. --id is pure IDs, while
--fields, --extended, and --include-artwork follow the standard
precedence. Artwork remains URL metadata only.
check, add, and remove accept only raw 22-character album IDs, typed
album URIs, or canonical album URLs. They use the same complete-batch
validation, first-seen deduplication, 40-URI generic-library request chunks,
ordered check output, success-only mutation output, and partial-failure
semantics as saved tracks. Album-specific membership endpoints are not used.
The command uses GET /me/playlists, defaults to 10, and accepts 1–50 results.
Its opaque continuation token is bound to playlist listing; provider response
URLs are never followed. Both playlist-read-collaborative and
playlist-read-private must be present before the provider request.
Default output is:
ID | PLAYLIST | OWNER_ID | OWNER | ITEM_COUNT | PUBLIC | COLLABORATIVE
PUBLIC renders - when Spotify returns a nullable state. --extended adds
URI, URL, SNAPSHOT_ID, and sanitized DESCRIPTION fields.
--include-artwork adds ARTWORK; --fields and --id follow the standard
precedence.
The command accepts one raw 22-character ID, matching playlist URI, or
canonical playlist URL under the same strict validation used by catalog gets.
It uses one fixed-origin GET /playlists/{id} request. Detail output uses the
stable identity header, and selected ID or PLAYLIST fields are not repeated
below it. Spotify currently permits this read only for playlists the current
user owns or collaborates on.
The command accepts the same playlist reference forms as playlists get and
uses the current fixed-origin GET /playlists/{id}/items endpoint with
additional_types=episode. It defaults to 10 items and accepts 1–50. Its
opaque continuation token is bound to the playlist-items surface and parsed
playlist ID; provider response URLs are never followed. The same current
provider owner-or-collaborator restriction applies.
Normal output renders Playlist ID: <id> once, followed by:
POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION
POSITION is the absolute zero-based page offset plus row index. TYPE is
track, episode, local, or unavailable for known wrapper shapes; a
future provider type is sanitized and preserved, while a non-null item without
a type renders unknown. Only track rows populate artist and album
breadcrumbs. Episode, local, unavailable, and unknown rows do not fabricate
track metadata.
--extended adds URI, URL, ADDED_AT, ADDED_BY_ID, DISC_NUMBER,
TRACK_NUMBER, EXPLICIT, and RESTRICTION. --include-artwork uses album
artwork for tracks and item artwork for episodes. --fields follows the
standard selection precedence. --id overrides every other shape flag,
omits the parent line, and emits only entries with a Spotify ID.
The command validates the playlist and every track reference before opening a
session. It preserves argument order and intentional duplicates. With no
--position, tracks append and the reported start position is the playlist's
current item count. An explicit zero-based position may range from zero through
that count. The command uses current fixed-origin
POST /playlists/{id}/items requests with uris in batches of at most 100;
positioned later chunks advance by their preceding chunk sizes.
Success emits added<TAB>playlist-id<TAB>start-position<TAB>count<TAB>snapshot-id.
If a later request is definitely rejected, the command emits that record for
the confirmed prefix and returns a partial-mutation error; a first-request
definite rejection emits no record. If transport, an ambiguous provider 5xx,
or a successful but invalid response makes the current batch outcome uncertain,
the command emits no stdout record, even for earlier confirmed batches. Its
sanitized reconciliation error
reports the playlist, start/current positions, confirmed and uncertain counts,
and last confirmed snapshot, and directs inspection before retrying. An
uncertain batch may have applied.
The record retains the position and count needed for an inverse operation.
Repeating guarded remove at the reported position is directly usable only while
every inserted track ID is
unique in the playlist; intentional or preexisting duplicates must first be
made unique by another Spotify client.
The command resolves one absolute position after reading the complete current
playlist. Unavailable, local, episode, future, and otherwise non-track items are
rejected. Because Spotify's current DELETE /playlists/{id}/items request
removes by URI and exposes no position, the command also rejects a track URI
that occurs more than once. Immediately before deletion it rechecks both the
snapshot and item count; stale state fails without a deletion request.
Success emits removed<TAB>playlist-id<TAB>position<TAB>track-id<TAB>snapshot-id.
Adding the reported track ID at the reported position is the inverse operation.
If the removal outcome is uncertain, the command emits no stdout record and its
sanitized reconciliation error reports the playlist position, track ID, and
prior snapshot. Inspect and reconcile before retrying; the deletion may have
applied.
For any playlist mutation, if stdout rejects an applied record, the command exits
nonzero and stderr includes the full sanitized recovery record while preserving
any partial-mutation cause. A nonzero remove therefore does not imply that no
deletion occurred.
The command reads the complete playlist and rejects out-of-range, non-track,
local, unavailable, future, same-item, duplicate-source, duplicate-replacement,
and stale states before writing. It adds the replacement at the requested
position, then removes the unique original URI using the add snapshot. This
preserves order and never exposes Spotify's whole-playlist replacement API.
Before removal, two playlist reads must bracket a complete ordered item read at
the add snapshot and original count plus one. Stable type, ID, and URI identity
must equal the exact original sequence with only the new track inserted at the
target; mutable display metadata is ignored. Any read failure or mismatch emits
no record, skips removal, and returns sanitized reconciliation guidance with the
confirmed add snapshot. The final request removes only the original occurrence
at its post-add position (position + 1) under that snapshot; it does not use
the URI-only all-occurrences shape used by guarded standalone removal.
Success emits
updated<TAB>playlist-id<TAB>position<TAB>old-track-id<TAB>new-track-id<TAB>snapshot-id.
Running update at the reported position with the old track ID is the inverse.
A definite provider 4xx add rejection emits no record. A definite provider
4xx remove rejection after a successful add emits no record and reports only
that the add succeeded at the included snapshot and this command's removal was
rejected without applying. It includes the playlist, position, and old/new IDs,
and directs inspection of the current playlist before recovery or retry because
collaborators may have moved or removed either item. An uncertain add likewise
emits no record and reports those identifiers without a confirmed post-write
snapshot. An uncertain remove also reports the confirmed add snapshot. Provider
5xx responses are uncertain; inspect before retrying because the step may have
applied. If final stdout fails after confirmed success, the error preserves the
complete inverse record.
All three playlist-item mutation commands require both playlist read scopes and both playlist modify scopes before any Spotify resource request. Mutation requests are never automatically replayed after ambiguous provider failures.
- Requests use fixed Spotify account/API origins; continuation data never supplies a URL to follow.
- OAuth access tokens refresh when possible and the refreshed credential is persisted back to the same allowed key.
- Read-only
429responses honorRetry-After; retryable502,503, and504read responses use conservative retries bounded to three total attempts. - Mutation
4xxresponses, including429, are definite provider rejections; mutation5xxresponses are outcome-uncertain and are never replayed. - Retries stop on context cancellation and never apply to unrelated client errors.
- Response/error bodies are size-limited before decoding or reporting.
- Authorization headers, access tokens, refresh tokens, authorization codes, and PKCE verifier/state material never appear in logs or errors.
The initial suite proves only the shipped surface.
- Interactive and non-interactive setup have flag parity.
- Browser, no-browser, redirected-URL stdin, denial, timeout, overwrite, verification, and rollback paths behave deterministically.
- Every production credential backend can be selected; memory is test-only.
set-credentialaccepts only the declared token key and secret ingress channels.- Configuration output reports non-secret values, credential presence, and backend metadata, but never credential contents or other secret values.
config clear,config clear --dry-run, andconfig clear --allremain scoped to the active credential reference.mesucceeds for valid/refreshable credentials and fails for missing, revoked, insufficient, or unreachable authorization.
Exercise ordinary, Spotify-qualified, quoted, Unicode, and guaranteed-no-match queries. Cover page sizes 1 and 10, continuation-token resume, ID-only output, explicit fields, extended fields, artwork inclusion, and exact stdout/stderr routing.
Invalid empty queries, non-positive or over-10 page sizes, malformed or wrong-surface page tokens, and unknown fields must fail before network I/O. Tests also cover token refresh, rate limiting, bounded transient retries, cancellation, bounded bodies, and cell sanitization.
Exercise ordinary, qualified, quoted, and Unicode queries. Cover exact default, ID-only, selected-field, extended, and artwork output; empty pages; page sizes 1 and 10; and continuation resume for both resource types.
Invalid queries, page sizes, fields, malformed tokens, and tokens issued for a different search resource fail before network I/O. Fixture tests verify fixed origin requests, response validation, and that Spotify pagination URLs are never followed.
Exercise raw ID, Spotify URI, and canonical Spotify URL references across tracks, albums, and artists. Cover exact default, ID-only, selected-field, extended, artwork, breadcrumb, and sanitized output. Malformed, wrong-kind, and hostile URL references must fail before credential or network access. Fixture tests verify exactly one fixed-origin single-resource request plus inherited authorization, retry, cancellation, and bounded-body behavior.
Exercise raw ID, Spotify URI, and canonical Spotify URL parents for album tracks and artist albums. Cover exact default, ID-only, selected-field, extended, artwork where available, empty-page, continuation, parent-header, and stdout/stderr behavior. Invalid parents, endpoint-specific page sizes, fields, and wrong-relationship or wrong-parent tokens fail before network I/O. Fixture tests verify fixed relationship paths, page validation, inherited transport behavior, and that provider pagination URLs are never followed.
Exercise list bounds, empty pages, continuation, shape flags, accepted reference forms, first-seen deduplication, complete-batch validation, exact scope-guard timing and hints, and stdout/stderr routing. Provider tests cover fixed paths and methods, response validation, check-length mismatch, inherited transport behavior, and 40/41/80/81 chunks including later-chunk failure.
Exercise list bounds, empty pages, album-bound continuation, default and selected fields, extended fields, artwork, pure IDs, multi-artist breadcrumbs, accepted album reference forms, first-seen deduplication, wrong-kind rejection, complete-batch validation, exact scope timing, and stdout/stderr routing. Provider tests cover the fixed saved-album list path, generic membership paths, response and check-length validation, inherited transport behavior, and 40/41/80/81 chunks including later-chunk failure.
Exercise list bounds, empty pages, nullable public state, missing optional owner/display values, default and selected fields, field deduplication, extended fields, sanitized descriptions, artwork, pure IDs, continuation, and exact stdout/stderr routing. Get covers raw ID, URI, and canonical URL forms, identity-field non-duplication, and hostile or wrong-kind rejection before authentication. Scope tests require both playlist-read scopes and the overwrite hint before provider I/O. Provider fixtures verify fixed paths, requested page metadata, non-negative totals/item counts, item bounds, response shape, and that provider continuation URLs are never followed.
Playlist-item tests cover accepted parent references, 1/50 bounds, absolute
positions across resumed pages, every known mixed-item shape plus future and
untyped items, exact parent/default/selected/extended/artwork/ID-only/empty
output, and validation before authentication. Provider fixtures prove the
current /items path and item member, exact page metadata, nonnegative
durations, required arrays, present item IDs, fixed origin, and ignored
provider pagination URLs. The static and opt-in live smokes prove the configured
three-ID prefix, positions and continuation derived from the runtime item count,
ID-only order, an exact middle replacement, and byte-for-byte restoration of
the captured full baseline after the inverse replacement. The replacement ID
comes from deterministic ID-only track search and must match the separately
configured safe mutation fixture before any playlist write.
Mutation tests additionally cover complete validation, insertion boundaries,
duplicate preservation, 100/101 chunking, full-list duplicate detection,
non-track rejection, stale snapshots/counts, compact inverse records,
definite first-request stdout suppression, accepted-prefix output after definite
partial success, and outcome-uncertain reconciliation without stdout. A
replacement test suite covers raw/URI/URL references, start/middle/end and
paginated order preservation, add-before-remove snapshot handoff, unsafe-state
rejection, definite partial failure, uncertain steps, and output failure. A
real-only build-tagged provider probe inserts two occurrences of the distinct
mutation track, waits through the bounded
provider write-settling window, proves one current URI removal removes both, and
polls for exact baseline restoration. The probe owns best-effort exact cleanup
for test failures, not abrupt process termination. The shell trap separately
protects the command round trip on failure or interruption.
Live tests are opt-in and use a dedicated Spotify application/account plus a hermetic state directory and an explicitly selected encrypted-file credential backend rooted there. They never run in ordinary CI or mutate a developer's normal Spotify configuration or OS keychain.
Future paginated commands use -m/--max, --next-page-token, and stderr
continuation hints. Future resource reads remain text-only and carry the
relationship breadcrumbs defined above. Batch playlist replacement, batch
positional removal, duplicate-URI force removal, episodes, local items,
playback, recommendations, podcasts, audiobooks, raw HTTP access, and local
media-library synchronization remain out of scope until separately specified.