UNPKG

@rocksky/cli

Version:

Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history

1,230 lines (1,203 loc) 38.8 kB
#!/usr/bin/env node import chalk from 'chalk'; import fs from 'fs'; import os from 'os'; import path from 'path'; import fs$1 from 'fs/promises'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import dayjs from 'dayjs'; import relative from 'dayjs/plugin/relativeTime.js'; import md5 from 'md5'; import { table, getBorderCharacters } from 'table'; import { Command } from 'commander'; import axios from 'axios'; import cors from 'cors'; import express from 'express'; import open from 'open'; const ROCKSKY_API_URL = "https://api.rocksky.app"; class RockskyClient { constructor(token) { this.token = token; this.token = token; } async getCurrentUser() { const response = await fetch(`${ROCKSKY_API_URL}/profile`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } }); if (!response.ok) { throw new Error(`Failed to fetch user data: ${response.statusText}`); } return response.json(); } async getSpotifyNowPlaying(did) { const response = await fetch( `${ROCKSKY_API_URL}/spotify/currently-playing` + (did ? `?did=${did}` : ""), { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error( `Failed to fetch now playing data: ${response.statusText}` ); } return response.json(); } async getNowPlaying(did) { const response = await fetch( `${ROCKSKY_API_URL}/now-playing` + (did ? `?did=${did}` : ""), { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error( `Failed to fetch now playing data: ${response.statusText}` ); } return response.json(); } async scrobbles(did, { skip = 0, limit = 20 } = {}) { if (did) { const response2 = await fetch( `${ROCKSKY_API_URL}/users/${did}/scrobbles?offset=${skip}&size=${limit}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response2.ok) { throw new Error( `Failed to fetch scrobbles data: ${response2.statusText}` ); } return response2.json(); } const response = await fetch( `${ROCKSKY_API_URL}/public/scrobbles?offset=${skip}&size=${limit}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`Failed to fetch scrobbles data: ${response.statusText}`); } return response.json(); } async search(query, { size }) { const response = await fetch( `${ROCKSKY_API_URL}/search?q=${query}&size=${size}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`Failed to fetch search data: ${response.statusText}`); } return response.json(); } async stats(did) { if (!did) { const didFile = path.join(os.homedir(), ".rocksky", "did"); try { await fs.promises.access(didFile); did = await fs.promises.readFile(didFile, "utf-8"); } catch (err) { const user = await this.getCurrentUser(); did = user.did; const didPath = path.join(os.homedir(), ".rocksky"); fs.promises.mkdir(didPath, { recursive: true }); await fs.promises.writeFile(didFile, did); } } const response = await fetch(`${ROCKSKY_API_URL}/users/${did}/stats`, { method: "GET", headers: { "Content-Type": "application/json" } }); if (!response.ok) { throw new Error(`Failed to fetch stats data: ${response.statusText}`); } return response.json(); } async getArtists(did, { skip = 0, limit = 20 } = {}) { if (!did) { const didFile = path.join(os.homedir(), ".rocksky", "did"); try { await fs.promises.access(didFile); did = await fs.promises.readFile(didFile, "utf-8"); } catch (err) { const user = await this.getCurrentUser(); did = user.did; const didPath = path.join(os.homedir(), ".rocksky"); fs.promises.mkdir(didPath, { recursive: true }); await fs.promises.writeFile(didFile, did); } } const response = await fetch( `${ROCKSKY_API_URL}/users/${did}/artists?offset=${skip}&size=${limit}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`Failed to fetch artists data: ${response.statusText}`); } return response.json(); } async getAlbums(did, { skip = 0, limit = 20 } = {}) { if (!did) { const didFile = path.join(os.homedir(), ".rocksky", "did"); try { await fs.promises.access(didFile); did = await fs.promises.readFile(didFile, "utf-8"); } catch (err) { const user = await this.getCurrentUser(); did = user.did; const didPath = path.join(os.homedir(), ".rocksky"); fs.promises.mkdir(didPath, { recursive: true }); await fs.promises.writeFile(didFile, did); } } const response = await fetch( `${ROCKSKY_API_URL}/users/${did}/albums?offset=${skip}&size=${limit}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`Failed to fetch albums data: ${response.statusText}`); } return response.json(); } async getTracks(did, { skip = 0, limit = 20 } = {}) { if (!did) { const didFile = path.join(os.homedir(), ".rocksky", "did"); try { await fs.promises.access(didFile); did = await fs.promises.readFile(didFile, "utf-8"); } catch (err) { const user = await this.getCurrentUser(); did = user.did; const didPath = path.join(os.homedir(), ".rocksky"); fs.promises.mkdir(didPath, { recursive: true }); await fs.promises.writeFile(didFile, did); } } const response = await fetch( `${ROCKSKY_API_URL}/users/${did}/tracks?offset=${skip}&size=${limit}`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`Failed to fetch tracks data: ${response.statusText}`); } return response.json(); } async scrobble(api_key, api_sig, track, artist, timestamp) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs.promises.access(tokenPath); } catch (err) { console.error( `You are not logged in. Please run the login command first.` ); return; } const tokenData = await fs.promises.readFile(tokenPath, "utf-8"); const { token: sk } = JSON.parse(tokenData); const response = await fetch("https://audioscrobbler.rocksky.app/2.0", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ method: "track.scrobble", "track[0]": track, "artist[0]": artist, "timestamp[0]": timestamp || Math.floor(Date.now() / 1e3), api_key, api_sig, sk, format: "json" }) }); if (!response.ok) { throw new Error( `Failed to scrobble track: ${response.statusText} ${await response.text()}` ); } return response.json(); } async getApiKeys() { const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, { method: "GET", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" } }); if (!response.ok) { throw new Error(`Failed to fetch API keys: ${response.statusText}`); } return response.json(); } async createApiKey(name, description) { const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, { method: "POST", headers: { Authorization: this.token ? `Bearer ${this.token}` : void 0, "Content-Type": "application/json" }, body: JSON.stringify({ name, description }) }); if (!response.ok) { throw new Error(`Failed to create API key: ${response.statusText}`); } return response.json(); } } async function albums$1(did, { skip, limit }) { const client = new RockskyClient(); const albums2 = await client.getAlbums(did, { skip, limit }); let rank = 1; for (const album of albums2) { console.log( `${rank} ${chalk.magenta(album.title)} ${album.artist} ${chalk.yellow( album.play_count + " plays" )}` ); rank++; } } async function artists$1(did, { skip, limit }) { const client = new RockskyClient(); const artists2 = await client.getArtists(did, { skip, limit }); let rank = 1; for (const artist of artists2) { console.log( `${rank} ${chalk.magenta(artist.name)} ${chalk.yellow( artist.play_count + " plays" )}` ); rank++; } } async function createApiKey$1(name, { description }) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const client = new RockskyClient(token); const apikey = await client.createApiKey(name, description); if (!apikey) { console.error(`Failed to create API key. Please try again later.`); return; } console.log(`API key created successfully!`); console.log(`Name: ${chalk.greenBright(apikey.name)}`); if (apikey.description) { console.log(`Description: ${chalk.greenBright(apikey.description)}`); } console.log(`Key: ${chalk.greenBright(apikey.api_key)}`); console.log(`Secret: ${chalk.greenBright(apikey.shared_secret)}`); } async function albums(did, { skip, limit = 20 }) { const client = new RockskyClient(); const albums2 = await client.getAlbums(did, { skip, limit }); let rank = 1; let response = `Top ${limit} albums: `; for (const album of albums2) { response += `${rank} ${album.title} - ${album.artist} - ${album.play_count} plays `; rank++; } return response; } async function artists(did, { skip, limit = 20 }) { try { const client = new RockskyClient(); const artists2 = await client.getArtists(did, { skip, limit }); let rank = 1; let response = `Top ${limit} artists: `; for (const artist of artists2) { response += `${rank} ${artist.name} - ${artist.play_count} plays `; rank++; } return response; } catch (err) { return `Failed to fetch artists data. Please check your token and try again, error: ${err.message}`; } } async function createApiKey(name, { description }) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const client = new RockskyClient(token); const apikey = await client.createApiKey(name, description); if (!apikey) { return "Failed to create API key. Please try again later."; } return "API key created successfully!, navigate to your Rocksky account to view it."; } dayjs.extend(relative); async function myscrobbles({ skip, limit }) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const client = new RockskyClient(token); try { const { did } = await client.getCurrentUser(); const scrobbles = await client.scrobbles(did, { skip, limit }); return JSON.stringify( scrobbles.map((scrobble) => ({ title: scrobble.title, artist: scrobble.artist, date: dayjs(scrobble.created_at + "Z").fromNow(), isoDate: scrobble.created_at })), null, 2 ); } catch (err) { return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`; } } async function nowplaying$1(did) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { if (!did) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token && !did) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const client = new RockskyClient(token); try { const nowPlaying = await client.getSpotifyNowPlaying(did); if (!nowPlaying || Object.keys(nowPlaying).length === 0) { const nowPlaying2 = await client.getNowPlaying(did); if (!nowPlaying2 || Object.keys(nowPlaying2).length === 0) { return "No track is currently playing."; } return JSON.stringify( { title: nowPlaying2.title, artist: nowPlaying2.artist, album: nowPlaying2.album }, null, 2 ); } return JSON.stringify( { title: nowPlaying.item.name, artist: nowPlaying.item.artists.map((a) => a.name).join(", "), album: nowPlaying.item.album.name }, null, 2 ); } catch (err) { return `Failed to fetch now playing data. Please check your token and try again, error: ${err.message}`; } } dayjs.extend(relative); async function scrobbles$1(did, { skip, limit }) { try { const client = new RockskyClient(); const scrobbles2 = await client.scrobbles(did, { skip, limit }); if (did) { return JSON.stringify( scrobbles2.map((scrobble) => ({ title: scrobble.title, artist: scrobble.artist, date: dayjs(scrobble.created_at + "Z").fromNow(), isoDate: scrobble.created_at })), null, 2 ); } return JSON.stringify( scrobbles2.map((scrobble) => ({ user: `@${scrobble.user}`, title: scrobble.title, artist: scrobble.artist, date: dayjs(scrobble.date).fromNow(), isoDate: scrobble.date })), null, 2 ); } catch (err) { return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`; } } async function search$1(query, { limit = 20, albums = false, artists = false, tracks = false, users = false }) { const client = new RockskyClient(); const results = await client.search(query, { size: limit }); if (results.records.length === 0) { return `No results found for ${query}.`; } let mergedResults = results.records.map((record) => ({ ...record, type: record.table })); if (albums) { mergedResults = mergedResults.filter((record) => record.table === "albums"); } if (artists) { mergedResults = mergedResults.filter( (record) => record.table === "artists" ); } if (tracks) { mergedResults = mergedResults.filter(({ table }) => table === "tracks"); } if (users) { mergedResults = mergedResults.filter(({ table }) => table === "users"); } mergedResults.sort((a, b) => b.xata_score - a.xata_score); const responses = []; for (const { table, record } of mergedResults) { if (table === "users") { responses.push({ handle: record.handle, display_name: record.display_name, did: record.did, link: `https://rocksky.app/profile/${record.did}`, type: "account" }); } if (table === "albums") { const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : ""; responses.push({ title: record.title, artist: record.artist, link, type: "album" }); } if (table === "tracks") { const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : ""; responses.push({ title: record.title, artist: record.artist, link, type: "track" }); } if (table === "artists") { const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : ""; responses.push({ name: record.name, link, type: "artist" }); } } return JSON.stringify(responses, null, 2); } async function stats$1(did) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { if (!did) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token && !did) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } try { const client = new RockskyClient(token); const stats2 = await client.stats(did); return JSON.stringify( { scrobbles: stats2.scrobbles, tracks: stats2.tracks, albums: stats2.albums, artists: stats2.artists, lovedTracks: stats2.lovedTracks }, null, 2 ); } catch (err) { return `Failed to fetch stats data. Please check your token and try again, error: ${err.message}`; } } async function tracks$1(did, { skip, limit = 20 }) { const client = new RockskyClient(); const tracks2 = await client.getTracks(did, { skip, limit }); let rank = 1; let response = `Top ${limit} tracks: `; for (const track of tracks2) { response += `${rank} ${track.title} - ${track.artist} - ${track.play_count} plays `; rank++; } return response; } async function whoami$1() { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { return "You are not logged in. Please run `rocksky login <username>.bsky.social` first."; } const client = new RockskyClient(token); try { const user = await client.getCurrentUser(); return `You are logged in as ${user.handle} (${user.displayName}). View your profile at: https://rocksky.app/profile/${user.handle}`; } catch (err) { return "Failed to fetch user data. Please check your token and try again."; } } class RockskyMcpServer { server; client; constructor() { this.server = new McpServer({ name: "rocksky-mcp", version: "0.1.0" }); this.setupTools(); } setupTools() { this.server.tool("whoami", "get the current logged-in user.", async () => { return { content: [ { type: "text", text: await whoami$1() } ] }; }); this.server.tool( "nowplaying", "get the currently playing track.", { did: z.string().optional().describe( "the DID or handle of the user to get the now playing track for." ) }, async ({ did }) => { return { content: [ { type: "text", text: await nowplaying$1(did) } ] }; } ); this.server.tool( "scrobbles", "display recently played tracks (recent scrobbles).", { did: z.string().optional().describe("the DID or handle of the user to get the scrobbles for."), skip: z.number().optional().describe("number of scrobbles to skip"), limit: z.number().optional().describe("number of scrobbles to limit") }, async ({ did, skip = 0, limit = 10 }) => { return { content: [ { type: "text", text: await scrobbles$1(did, { skip, limit }) } ] }; } ); this.server.tool( "my-scrobbles", "display my recently played tracks (recent scrobbles).", { skip: z.number().optional().describe("number of scrobbles to skip"), limit: z.number().optional().describe("number of scrobbles to limit") }, async ({ skip = 0, limit = 10 }) => { return { content: [ { type: "text", text: await myscrobbles({ skip, limit }) } ] }; } ); this.server.tool( "search", "search for tracks, artists, albums or users.", { query: z.string().describe("the search query, e.g., artist, album, title or account"), limit: z.number().optional().describe("number of results to limit"), albums: z.boolean().optional().describe("search for albums"), tracks: z.boolean().optional().describe("search for tracks"), users: z.boolean().optional().describe("search for users"), artists: z.boolean().optional().describe("search for artists") }, async ({ query, limit = 10, albums: albums2 = false, tracks: tracks2 = false, users = false, artists: artists2 = false }) => { return { content: [ { type: "text", text: await search$1(query, { limit, albums: albums2, tracks: tracks2, users, artists: artists2 }) } ] }; } ); this.server.tool( "artists", "get the user's top artists or current user's artists if no did is provided.", { did: z.string().optional().describe("the DID or handle of the user to get artists for."), limit: z.number().optional().describe("number of results to limit") }, async ({ did, limit }) => { return { content: [ { type: "text", text: await artists(did, { skip: 0, limit }) } ] }; } ); this.server.tool( "albums", "get the user's top albums or current user's albums if no did is provided.", { did: z.string().optional().describe("the DID or handle of the user to get albums for."), limit: z.number().optional().describe("number of results to limit") }, async ({ did, limit }) => { return { content: [ { type: "text", text: await albums(did, { skip: 0, limit }) } ] }; } ); this.server.tool( "tracks", "get the user's top tracks or current user's tracks if no did is provided.", { did: z.string().optional().describe("the DID or handle of the user to get tracks for."), limit: z.number().optional().describe("number of results to limit") }, async ({ did, limit }) => { return { content: [ { type: "text", text: await tracks$1(did, { skip: 0, limit }) } ] }; } ); this.server.tool( "stats", "get the user's listening stats or current user's stats if no did is provided.", { did: z.string().optional().describe("the DID or handle of the user to get stats for.") }, async ({ did }) => { return { content: [ { type: "text", text: await stats$1(did) } ] }; } ); this.server.tool( "create-apikey", "create an API key.", { name: z.string().describe("the name of the API key"), description: z.string().optional().describe("the description of the API key") }, async ({ name, description }) => { return { content: [ { type: "text", text: await createApiKey(name, { description }) } ] }; } ); } async run() { const stdioTransport = new StdioServerTransport(); try { await this.server.connect(stdioTransport); } catch (error) { process.exit(1); } } getServer() { return this.server; } } const rockskyMcpServer = new RockskyMcpServer(); function mcp() { rockskyMcpServer.run().catch((error) => { console.error("Failed to run Rocksky MCP server", { error }); process.exit(1); }); } async function nowplaying(did) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { if (!did) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token && !did) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const client = new RockskyClient(token); try { const nowPlaying = await client.getSpotifyNowPlaying(did); if (!nowPlaying || Object.keys(nowPlaying).length === 0) { const nowPlaying2 = await client.getNowPlaying(did); if (!nowPlaying2 || Object.keys(nowPlaying2).length === 0) { console.log("No track is currently playing."); return; } console.log(chalk.magenta(`${nowPlaying2.title} - ${nowPlaying2.artist}`)); console.log(`${nowPlaying2.album}`); return; } console.log( chalk.magenta( `${nowPlaying.item.name} - ${nowPlaying.item.artists.map((a) => a.name).join(", ")}` ) ); console.log(`${nowPlaying.item.album.name}`); } catch (err) { console.log(err); console.error( `Failed to fetch now playing data. Please check your token and try again.` ); } } async function scrobble(track, artist, { timestamp }) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const client = new RockskyClient(token); const apikeys = await client.getApiKeys(); if (!apikeys || apikeys.length === 0 || !apikeys[0].enabled) { console.error( `You don't have any API keys. Please create one using ${chalk.greenBright( "`rocksky create apikey`" )} command.` ); return; } const signature = md5( `api_key${apikeys[0].apiKey}artist[0]${artist}methodtrack.scrobblesk${token}timestamp[0]${timestamp || Math.floor(Date.now() / 1e3)}track[0]${track}${apikeys[0].sharedSecret}` ); await client.scrobble( apikeys[0].apiKey, signature, track, artist, timestamp ); console.log( `Scrobbled ${chalk.greenBright(track)} by ${chalk.greenBright( artist )} at ${chalk.greenBright( new Date( (timestamp || Math.floor(Date.now() / 1e3)) * 1e3 ).toLocaleString() )}` ); } dayjs.extend(relative); async function scrobbles(did, { skip, limit }) { const client = new RockskyClient(); const scrobbles2 = await client.scrobbles(did, { skip, limit }); for (const scrobble of scrobbles2) { if (did) { console.log( `${chalk.bold.magenta(scrobble.title)} ${scrobble.artist} ${chalk.yellow(dayjs(scrobble.created_at + "Z").fromNow())}` ); continue; } const handle = `@${scrobble.user}`; console.log( `${chalk.italic.magentaBright( handle )} is listening to ${chalk.bold.magenta(scrobble.title)} ${scrobble.artist} ${chalk.yellow(dayjs(scrobble.date).fromNow())}` ); } } async function search(query, { limit = 20, albums = false, artists = false, tracks = false, users = false }) { const client = new RockskyClient(); const results = await client.search(query, { size: limit }); if (results.records.length === 0) { console.log(`No results found for ${chalk.magenta(query)}.`); return; } let mergedResults = results.records.map((record) => ({ ...record, type: record.table })); if (albums) { mergedResults = mergedResults.filter((record) => record.table === "albums"); } if (artists) { mergedResults = mergedResults.filter( (record) => record.table === "artists" ); } if (tracks) { mergedResults = mergedResults.filter(({ table }) => table === "tracks"); } if (users) { mergedResults = mergedResults.filter(({ table }) => table === "users"); } mergedResults.sort((a, b) => b.xata_score - a.xata_score); for (const { table, record } of mergedResults) { if (table === "users") { console.log( `${chalk.bold.magenta(record.handle)} ${record.display_name} ${chalk.yellow(`https://rocksky.app/profile/${record.did}`)}` ); } if (table === "albums") { const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : ""; console.log( `${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow( link )}` ); } if (table === "tracks") { const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : ""; console.log( `${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow( link )}` ); } } } async function stats(did) { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { if (!did) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token && !did) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const client = new RockskyClient(token); const stats2 = await client.stats(did); console.log( table( [ ["Scrobbles", chalk.magenta(stats2.scrobbles)], ["Tracks", chalk.magenta(stats2.tracks)], ["Albums", chalk.magenta(stats2.albums)], ["Artists", chalk.magenta(stats2.artists)], ["Loved Tracks", chalk.magenta(stats2.lovedTracks)] ], { border: getBorderCharacters("void"), columnDefault: { paddingLeft: 0, paddingRight: 1 }, drawHorizontalLine: () => false } ) ); } async function tracks(did, { skip, limit }) { const client = new RockskyClient(); const tracks2 = await client.getTracks(did, { skip, limit }); let rank = 1; for (const track of tracks2) { console.log( `${rank} ${chalk.magenta(track.title)} ${track.artist} ${chalk.yellow( track.play_count + " plays" )}` ); rank++; } } async function whoami() { const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); try { await fs$1.access(tokenPath); } catch (err) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const tokenData = await fs$1.readFile(tokenPath, "utf-8"); const { token } = JSON.parse(tokenData); if (!token) { console.error( `You are not logged in. Please run ${chalk.greenBright( "`rocksky login <username>.bsky.social`" )} first.` ); return; } const client = new RockskyClient(token); try { const user = await client.getCurrentUser(); console.log(`You are logged in as ${user.handle} (${user.displayName}).`); console.log( `View your profile at: ${chalk.magenta( `https://rocksky.app/profile/${user.handle}` )}` ); } catch (err) { console.error( `Failed to fetch user data. Please check your token and try again.` ); } } var version = "0.2.0"; var version$1 = { version: version}; async function login(handle) { const app = express(); app.use(cors()); app.use(express.json()); const server = app.listen(6996); app.post("/token", async (req, res) => { console.log(chalk.bold(chalk.greenBright("Login successful!\n"))); console.log( "You can use this session key (Token) to authenticate with the API." ); console.log("Received token (session key):", chalk.green(req.body.token)); const tokenPath = path.join(os.homedir(), ".rocksky", "token.json"); await fs$1.mkdir(path.dirname(tokenPath), { recursive: true }); await fs$1.writeFile( tokenPath, JSON.stringify({ token: req.body.token }, null, 2) ); res.json({ ok: 1 }); server.close(); }); const response = await axios.post("https://api.rocksky.app/login", { handle, cli: true }); const redirectUrl = response.data; if (!redirectUrl.includes("authorize")) { console.error("Failed to login, please check your handle and try again."); server.close(); return; } console.log("Please visit this URL to authorize the app:"); console.log(chalk.cyan(redirectUrl)); open(redirectUrl); } const program = new Command(); program.name("rocksky").description( `Command-line interface for Rocksky (${chalk.underline( "https://rocksky.app" )}) \u2013 scrobble tracks, view stats, and manage your listening history.` ).version(version$1.version); program.command("login").argument("<handle>", "your BlueSky handle (e.g., <username>.bsky.social)").description("login with your BlueSky account and get a session token.").action(login); program.command("whoami").description("get the current logged-in user.").action(whoami); program.command("nowplaying").argument( "[did]", "the DID or handle of the user to get the now playing track for." ).description("get the currently playing track.").action(nowplaying); program.command("scrobbles").option("-s, --skip <number>", "number of scrobbles to skip").option("-l, --limit <number>", "number of scrobbles to limit").argument("[did]", "the DID or handle of the user to get the scrobbles for.").description("display recently played tracks.").action(scrobbles); program.command("search").option("-a, --albums", "search for albums").option("-t, --tracks", "search for tracks").option("-u, --users", "search for users").option("-l, --limit <number>", "number of results to limit").argument( "<query>", "the search query, e.g., artist, album, title or account" ).description("search for tracks, albums, or accounts.").action(search); program.command("stats").option("-l, --limit <number>", "number of results to limit").argument("[did]", "the DID or handle of the user to get stats for.").description("get the user's listening stats.").action(stats); program.command("artists").option("-l, --limit <number>", "number of results to limit").argument("[did]", "the DID or handle of the user to get artists for.").description("get the user's top artists.").action(artists$1); program.command("albums").option("-l, --limit <number>", "number of results to limit").argument("[did]", "the DID or handle of the user to get albums for.").description("get the user's top albums.").action(albums$1); program.command("tracks").option("-l, --limit <number>", "number of results to limit").argument("[did]", "the DID or handle of the user to get tracks for.").description("get the user's top tracks.").action(tracks); program.command("scrobble").argument("<track>", "the title of the track").argument("<artist>", "the artist of the track").option("-t, --timestamp <timestamp>", "the timestamp of the scrobble").description("scrobble a track to your profile.").action(scrobble); program.command("create").description("create a new API key.").command("apikey").argument("<name>", "the name of the API key").option("-d, --description <description>", "the description of the API key").description("create a new API key.").action(createApiKey$1); program.command("mcp").description("Starts an MCP server to use with Claude or other LLMs.").action(mcp); program.parse(process.argv);