Skip to content
Merged
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
42 changes: 42 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions docs/commands/spotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Control the Spotify application.

Most commands send instructions directly to the Spotify app on your Mac via AppleScript. The [speakers](#speakers) and [speaker](#speaker) commands use the [Spotify Web API](https://developer.spotify.com/documentation/web-api) and require a Spotify Developer account and a client ID — see [login](#login) for setup.

## Table of Contents

- [launch](#launch)
Expand All @@ -13,6 +15,10 @@ Control the Spotify application.
- [now](#now)
- [volume-up](#volume-up)
- [volume-down](#volume-down)
- [login](#login)
- [logout](#logout)
- [speakers](#speakers)
- [speaker](#speaker)

## launch

Expand Down Expand Up @@ -85,3 +91,49 @@ Decrease Spotify volume by 5.
```sh
bitbard spotify volume-down
```

## login

Log in to Spotify via OAuth to enable Web API commands. Requires a client ID from a [Spotify Developer app](https://developer.spotify.com/dashboard).

```sh
bitbard spotify login --client-id <your-client-id>
```

This opens a browser window to complete the OAuth flow. Once authenticated, credentials are stored in the macOS Keychain and reused by subsequent commands. If you are already logged in, the command exits early with a reminder to run `logout` first if you want to switch accounts.

## logout

Log out of Spotify, removing stored credentials.

```sh
bitbard spotify logout
```

## speakers

List available [Spotify Connect](https://www.spotify.com/connect/) devices.

```sh
bitbard spotify speakers
```

Devices are retrieved via the Spotify Web API and must be active (i.e. have Spotify open) to appear. Requires [login](#login).

> **Note:** Only devices that support Spotify Connect are listed. Some speaker types may not appear due to limitations of the Spotify Web API.

## speaker

Switch Spotify playback to a different device.

```sh
# Interactive — shows a prompt to pick a device
bitbard spotify speaker

# Direct — transfer to a specific device by ID
bitbard spotify speaker <device-id>
```

If no device ID is provided, an interactive prompt lists all available devices and their active state. Requires [login](#login).

> **Note:** Only devices that support Spotify Connect are listed. Some speaker types may not appear due to limitations of the Spotify Web API.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "bitbard",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"workspaces": {
"packages": [
"packages/*"
Expand All @@ -13,6 +13,7 @@
"@vitest/coverage-v8": "^4.1.5",
"chalk": "^5.6.2",
"citty": "^0.2.2",
"open": "^11.0.0",
"oxfmt": "^0.48.0",
"oxlint": "^1.62.0",
"rolldown": "^1.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@bitbard/core": "workspace:*",
"@bitbard/spotify": "workspace:*",
"@clack/prompts": "catalog:",
"chalk": "catalog:",
"citty": "catalog:"
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/spotify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import next from './next.js';
import now from './now.js';
import volumeUp from './volume-up.js';
import volumeDown from './volume-down.js';
import login from './login.js';
import logout from './logout.js';
import { macosPlugin } from '../../plugins/macos.js';
import speakers from './speakers.js';
import speaker from './speaker.js';

export default defineCommand({
meta: {
Expand All @@ -17,11 +21,15 @@ export default defineCommand({
},
plugins: [macosPlugin],
subCommands: {
login,
logout,
launch,
play,
pause,
playpause,
previous,
speakers,
speaker,
next,
now,
'volume-up': volumeUp,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/launch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { launch } from '@bitbard/core/spotify.js';
import { launch } from '@bitbard/spotify/app.js';

export default defineCommand({
meta: {
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/spotify/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineCommand } from 'citty';
import { login, isLoggedIn } from '@bitbard/spotify/auth.js';
import { spinner } from '@clack/prompts';

export default defineCommand({
meta: {
name: 'login',
description: 'Log in to Spotify via OAuth (requires your Spotify Developer client ID)',
},
args: {
'client-id': {
type: 'string',
description: 'Your Spotify Developer app client ID',
required: true,
},
},
async run({ args }) {
if (await isLoggedIn()) {
console.log('Already logged in to Spotify. Run: bitbard spotify logout to switch accounts.');
return;
}

const s = spinner();
s.start('Waiting for Spotify login in browser…');
try {
await login(args['client-id']);
} catch (err) {
s.stop('Login failed');
throw err;
}
s.stop('Logged in to Spotify');
},
});
15 changes: 15 additions & 0 deletions packages/cli/src/commands/spotify/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineCommand } from 'citty';
import { logout } from '@bitbard/spotify/auth.js';
import { log } from '@clack/prompts';

export default defineCommand({
meta: {
name: 'logout',
description: 'Log out of Spotify',
},
async run() {
await logout();

log.success('Logged out of Spotify');
},
});
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/next.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { nextTrack } from '@bitbard/core/spotify.js';
import { nextTrack } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/now.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineCommand } from 'citty';
import chalk from 'chalk';
import { getNowPlaying } from '@bitbard/core/spotify.js';
import { getNowPlaying } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/pause.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { pause } from '@bitbard/core/spotify.js';
import { pause } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/play.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { play } from '@bitbard/core/spotify.js';
import { play } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/playpause.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { playpause } from '@bitbard/core/spotify.js';
import { playpause } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spotify/previous.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineCommand } from 'citty';
import { previousTrack } from '@bitbard/core/spotify.js';
import { previousTrack } from '@bitbard/spotify/playback.js';

export default defineCommand({
meta: {
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/spotify/speaker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { defineCommand } from 'citty';
import { select, isCancel } from '@clack/prompts';
import chalk from 'chalk';
import { getDevices, transferPlayback } from '@bitbard/spotify/devices.js';

export default defineCommand({
meta: {
name: 'speaker',
description: 'Switch Spotify playback to a device (interactive if no ID given)',
},
args: {
id: {
type: 'positional',
description: 'Spotify device ID (optional — shows list if omitted)',
required: false,
},
},
async run({ args }) {
const devices = await getDevices();

if (devices.length === 0) {
console.log('No devices found. Open Spotify on a device first.');
return;
}

if (args.id) {
const device = devices.find((d) => d.id === args.id);
if (!device) {
console.log(chalk.red(`No device with ID "${args.id}" found.`));
return;
}
await transferPlayback(device.id);
console.log(chalk.bold(chalk.green(`Playback transferred to ${device.name}.`)));
return;
}

const choice = await select({
message: 'Select a device',
options: devices.map((d) => ({
value: d.id,
label: `${d.name} ${chalk.dim(d.type)}${d.isActive ? chalk.green(' (active)') : ''}`,
})),
});

if (isCancel(choice)) {
return;
}

const selected = devices.find((d) => d.id === choice)!;
await transferPlayback(choice as string);
console.log(chalk.bold(chalk.green(`Playback transferred to ${selected.name}.`)));
},
});
Loading