#!/usr/bin/env node import chalk from "chalk"; import { Command } from "commander"; import { version } from "../package.json" assert { type: "json" }; import { c } from "./theme"; // Command modules are imported lazily inside each action so that a bare // `rocksky` (TUI) launch doesn't pay to load drizzle/better-sqlite3, express, // hono, the MCP SDK, etc. — only the one command the user actually runs. const lazy = (load: () => Promise<(...args: T) => unknown>) => async (...args: T) => { await ensureDb(); return (await load())(...args); }; let dbReady: Promise | null = null; function ensureDb() { if (!dbReady) { dbReady = import("./drizzle").then(({ initializeDatabase }) => initializeDatabase(), ); } return dbReady; } const program = new Command(); program .name("rocksky") .description( ` ___ __ __ _______ ____ / _ \\___ ____/ /__ ___ / /____ __ / ___/ / / _/ / , _/ _ \\/ __/ '_/(_- chalk.bold(c.primary(str)), styleCommandText: (str) => c.secondary(str), styleDescriptionText: (str) => c.muted(str), styleOptionText: (str) => c.highlight(str), styleArgumentText: (str) => c.accent(str), styleSubcommandText: (str) => c.secondary(str), }); program.addHelpText( "after", ` ${chalk.bold(c.primary("\nLearn more about Rocksky:"))} ${c.link("https://docs.rocksky.app")} ${chalk.bold(c.primary("Join our Discord community:"))} ${c.link("https://discord.gg/EVcBy2fVa3")} `, ); program .command("login") .argument("", "your AT Proto handle (e.g., .bsky.social)") .description("login with your AT Proto account and get a session token") .action(lazy(async () => (await import("./cmd/login")).login)); program .command("whoami") .description("get the current logged-in user") .action(lazy(async () => (await import("cmd/whoami")).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(lazy(async () => (await import("cmd/nowplaying")).nowplaying)); program .command("scrobbles") .option("-s, --skip ", "number of scrobbles to skip") .option("-l, --limit ", "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(lazy(async () => (await import("cmd/scrobbles")).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 of results to limit") .argument( "", "the search query, e.g., artist, album, title or account", ) .description("search for tracks, albums, or accounts") .action(lazy(async () => (await import("cmd/search")).search)); program .command("stats") .option("-l, --limit ", "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(lazy(async () => (await import("cmd/stats")).stats)); program .command("artists") .option("-l, --limit ", "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(lazy(async () => (await import("cmd/artists")).artists)); program .command("albums") .option("-l, --limit ", "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(lazy(async () => (await import("cmd/albums")).albums)); program .command("tracks") .option("-l, --limit ", "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(lazy(async () => (await import("cmd/tracks")).tracks)); program .command("scrobble") .argument("", "the title of the track") .argument("", "the artist of the track") .option( "-a, --album ", "the album of the track — matching prefers this release over remaster/live/single editions", ) .option("-t, --timestamp ", "the timestamp of the scrobble") .option("-d, --dry-run", "simulate the scrobble without actually sending it") .description("scrobble a track to your profile") .action(lazy(async () => (await import("cmd/scrobble")).scrobble)); program .command("create") .description("create a new API key") .command("apikey") .argument("", "the name of the API key") .option("-d, --description ", "the description of the API key") .description("create a new API key") .action(lazy(async () => (await import("cmd/create")).createApiKey)); program .command("mcp") .description("starts an MCP server to use with Claude or other LLMs") .option( "-d, --device ", "player the command tools address when a call names none", ) .option("--api-url ", "Rocksky API base URL, for a dev server") .option("--ws-url ", "remote-control WebSocket URL, for a dev server") .option("--token-path ", "where the access token lives") // Not wrapped in `lazy`: the MCP server is a separate binary with its own // state, so initializing the CLI's local database here would be pure cost. .action(async (options) => (await import("cmd/mcp")).mcp(options)); program .command("sync") .description("sync your local Rocksky data from AT Protocol") .action(lazy(async () => (await import("cmd/sync")).sync)); program .command("scrobble-api") .description("start a local listenbrainz/lastfm compatibility server") .option("-p, --port ", "the port to listen on", "8778") .action(lazy(async () => (await import("cmd/scrobble-api")).scrobbleApi)); // The MPD server needs only the API + token + player, no local database. program .command("mpd") .description( "start an MPD-protocol server to control playback and browse your library", ) .option("-p, --port ", "port to listen on (defaults to settings.toml)") .option("-b, --bind
", "address to bind (defaults to settings.toml)") .option( "-o, --output ", "audio output backend: default device, or stdout / fifo: / unix: / tcp:: (defaults to settings.toml)", ) .action(async (opts: { port?: string; bind?: string; output?: string }) => { const { mpd } = await import("cmd/mpd"); await mpd(opts); }); // NFC talks to the reader and the library API only — no local database. const nfcCmd = program .command("nfc") .description( "read and write NFC tags that play a library album, playlist or your favorites", ); nfcCmd .command("status") .description("list connected NFC readers") .action(async () => { const { nfcStatus } = await import("cmd/nfc"); await nfcStatus(); }); nfcCmd .command("read") .option("-w, --watch", "keep reading tags until interrupted") .description("show what the tapped tag points at") .action(async (opts: { watch?: boolean }) => { const { nfcRead } = await import("cmd/nfc"); await nfcRead(opts); }); // Contact cards (SLE, ACOS) carry the same records as an NFC tag but speak // entirely different protocols, so they get their own command rather than // sharing the reader with the tag watcher. const cardCmd = program .command("card") .description( "read and write SLE and ACOS smart cards that play a library album or playlist", ); cardCmd .command("read") .description("show what the inserted card points at") .action(async () => { const { cardRead } = await import("cmd/card"); await cardRead(); }); cardCmd .command("write") .option("-a, --album ", "album AT-URI or library id to write") .option("-p, --playlist ", "playlist AT-URI or library id to write") .option("-f, --favorites", "write your favorites (needs no ref)") .option( "-s, --secret ", "the card's PSC (SLE, hex) or PIN (ACOS); defaults to the factory value", ) .description( "write a library album, playlist or your favorites onto the inserted card", ) .action( async (opts: { album?: string; playlist?: string; favorites?: boolean; secret?: string; }) => { const { cardWrite } = await import("cmd/card"); await cardWrite(opts); }, ); nfcCmd .command("write") .option("-a, --album ", "library album id to write") .option("-p, --playlist ", "library playlist id to write") .option("-f, --favorites", "write your favorites (needs no id)") .description( "write a library album, playlist or your favorites onto the tapped tag", ) .action( async (opts: { album?: string; playlist?: string; favorites?: boolean }) => { const { nfcWrite } = await import("cmd/nfc"); await nfcWrite(opts); }, ); // Import reads an export file and publishes via @rocksky/sdk — no local DB. program .command("import") .argument( "", "a Spotify or Last.fm export (JSON/CSV file, or the Spotify history folder)", ) .option("-f, --format ", "force the format: spotify or lastfm (default: autodetect)") .option("-n, --dry-run", "parse and print what would be published, without writing to your PDS") .option("-c, --concurrency ", "number of scrobbles to publish in parallel", "4") .option( "-r, --rate ", "scrobbles published per hour (hard-capped to stay under Bluesky PDS write limits; each scrobble writes up to 4 records)", ) .option( "--no-rate-limit", "disable the client-side write throttle — self-hosted PDSes only; always ignored on *.bsky.network", ) .option("-l, --limit ", "only import the first N scrobbles") .option("--min-seconds ", "skip Spotify plays shorter than this (default: 30)") .option("--from ", "only import scrobbles on or after this date (e.g. 2024-01-01)") .option("--to ", "only import scrobbles on or before this date") .option("--restart", "ignore the saved checkpoint and import from the beginning") .description("import your listening history from a Spotify or Last.fm export") .action(async (file: string, opts) => { const { importCmd } = await import("cmd/import"); await importCmd(file, opts); }); // Upload needs only the API + token, no local database. program .command("upload") .argument("", "audio files or directories to upload") .option( "-s, --storage ", "upload into your own connected storage (name, bucket or id) instead of Rocksky's", ) .description("upload audio files to your Rocksky library") .action(async (files: string[], opts: { storage?: string }) => { const { upload } = await import("cmd/upload"); await upload(files, opts); }); // The TUI needs no local database, so it skips `ensureDb()` entirely. program .command("tui") .description( "launch the interactive terminal UI to browse scrobbles and stream your music", ) .option( "-o, --output ", "audio output backend: default device, or stdout / fifo: / unix: / tcp:: (defaults to settings.toml)", ) .action(async (opts: { output?: string }) => { const { tui } = await import("cmd/tui"); await tui(opts); }); // No subcommand given → launch the interactive TUI (no DB init, lean startup). if (process.argv.slice(2).length === 0) { const { tui } = await import("cmd/tui"); await tui(); } else { program.parse(process.argv); }