@crafter/spoti-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +449 -0
- package/package.json +39 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/lib/auth-server.ts
|
|
7
|
+
import { createServer } from "http";
|
|
8
|
+
var PORT = 8888;
|
|
9
|
+
function startAuthServer() {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const connections = /* @__PURE__ */ new Set();
|
|
12
|
+
const server = createServer((req, res) => {
|
|
13
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
|
14
|
+
if (url.pathname === "/callback") {
|
|
15
|
+
const code = url.searchParams.get("code");
|
|
16
|
+
const error = url.searchParams.get("error");
|
|
17
|
+
if (error) {
|
|
18
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
19
|
+
res.end(authPage("Authentication failed. You can close this tab.", true));
|
|
20
|
+
reject(new Error(`Auth failed: ${error}`));
|
|
21
|
+
setTimeout(forceClose, 100);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (code) {
|
|
25
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
26
|
+
res.end(authPage("Authentication successful! Return to your terminal."));
|
|
27
|
+
resolve(code);
|
|
28
|
+
setTimeout(forceClose, 100);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
res.writeHead(400);
|
|
32
|
+
res.end("Missing code");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(404);
|
|
36
|
+
res.end("Not found");
|
|
37
|
+
});
|
|
38
|
+
server.on("connection", (conn) => {
|
|
39
|
+
connections.add(conn);
|
|
40
|
+
conn.on("close", () => connections.delete(conn));
|
|
41
|
+
});
|
|
42
|
+
const forceClose = () => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
for (const conn of connections) conn.destroy();
|
|
45
|
+
server.close();
|
|
46
|
+
server.unref();
|
|
47
|
+
};
|
|
48
|
+
server.listen(PORT, "127.0.0.1");
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
forceClose();
|
|
51
|
+
reject(new Error("Auth timeout after 120s"));
|
|
52
|
+
}, 12e4);
|
|
53
|
+
timeout.unref();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function authPage(message, isError = false) {
|
|
57
|
+
const color = isError ? "#ef4444" : "#22c55e";
|
|
58
|
+
return `<!DOCTYPE html>
|
|
59
|
+
<html>
|
|
60
|
+
<head><title>spoti-cli</title></head>
|
|
61
|
+
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa">
|
|
62
|
+
<div style="text-align:center">
|
|
63
|
+
<h1 style="color:${color}">${message}</h1>
|
|
64
|
+
<p style="color:#888">spoti-cli</p>
|
|
65
|
+
</div>
|
|
66
|
+
</body>
|
|
67
|
+
</html>`;
|
|
68
|
+
}
|
|
69
|
+
var REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
|
70
|
+
|
|
71
|
+
// src/lib/config.ts
|
|
72
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
73
|
+
import { homedir } from "os";
|
|
74
|
+
import { join } from "path";
|
|
75
|
+
var CONFIG_DIR = join(homedir(), ".spoti-cli");
|
|
76
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
77
|
+
function ensureConfigDir() {
|
|
78
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
79
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function readConfig() {
|
|
83
|
+
ensureConfigDir();
|
|
84
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
85
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
86
|
+
}
|
|
87
|
+
function writeConfig(config) {
|
|
88
|
+
ensureConfigDir();
|
|
89
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
90
|
+
}
|
|
91
|
+
function updateConfig(partial) {
|
|
92
|
+
const current = readConfig();
|
|
93
|
+
writeConfig({ ...current, ...partial });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/commands/auth.ts
|
|
97
|
+
var SCOPES = [
|
|
98
|
+
"playlist-modify-private",
|
|
99
|
+
"playlist-modify-public",
|
|
100
|
+
"user-read-private",
|
|
101
|
+
"user-read-email"
|
|
102
|
+
];
|
|
103
|
+
function generateCodeVerifier() {
|
|
104
|
+
const array = new Uint8Array(64);
|
|
105
|
+
crypto.getRandomValues(array);
|
|
106
|
+
return btoa(String.fromCharCode(...array)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_").slice(0, 128);
|
|
107
|
+
}
|
|
108
|
+
async function generateCodeChallenge(verifier) {
|
|
109
|
+
const encoder = new TextEncoder();
|
|
110
|
+
const data = encoder.encode(verifier);
|
|
111
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
112
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
113
|
+
}
|
|
114
|
+
async function authCommand(clientId) {
|
|
115
|
+
const config = readConfig();
|
|
116
|
+
const id = clientId ?? config.client_id;
|
|
117
|
+
if (!id) {
|
|
118
|
+
console.error("Client ID required. Run: spoti-cli auth --client-id <ID>");
|
|
119
|
+
console.error("Get one at: https://developer.spotify.com/dashboard/create");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
updateConfig({ client_id: id });
|
|
123
|
+
const codeVerifier = generateCodeVerifier();
|
|
124
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
125
|
+
const authUrl = new URL("https://accounts.spotify.com/authorize");
|
|
126
|
+
authUrl.searchParams.set("response_type", "code");
|
|
127
|
+
authUrl.searchParams.set("client_id", id);
|
|
128
|
+
authUrl.searchParams.set("scope", SCOPES.join(" "));
|
|
129
|
+
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
130
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
131
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
132
|
+
console.log("Opening browser for Spotify login...");
|
|
133
|
+
console.log(`If it doesn't open, visit: ${authUrl.toString()}`);
|
|
134
|
+
const open = (await import("open")).default;
|
|
135
|
+
await open(authUrl.toString());
|
|
136
|
+
const code = await startAuthServer();
|
|
137
|
+
console.log("Exchanging code for tokens...");
|
|
138
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
141
|
+
body: new URLSearchParams({
|
|
142
|
+
grant_type: "authorization_code",
|
|
143
|
+
code,
|
|
144
|
+
redirect_uri: REDIRECT_URI,
|
|
145
|
+
client_id: id,
|
|
146
|
+
code_verifier: codeVerifier
|
|
147
|
+
})
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const err = await res.text();
|
|
151
|
+
console.error(`Token exchange failed: ${err}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
updateConfig({
|
|
156
|
+
access_token: data.access_token,
|
|
157
|
+
refresh_token: data.refresh_token,
|
|
158
|
+
expires_at: Date.now() + data.expires_in * 1e3
|
|
159
|
+
});
|
|
160
|
+
console.log("Authenticated successfully!");
|
|
161
|
+
console.log("");
|
|
162
|
+
console.log("Install the Claude skill for AI-powered playlists:");
|
|
163
|
+
console.log(" npx skills add crafter-station/skills --skill spoti-cli");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/lib/output.ts
|
|
167
|
+
function output(data, json) {
|
|
168
|
+
if (json) {
|
|
169
|
+
console.log(JSON.stringify(data, null, 2));
|
|
170
|
+
} else if (Array.isArray(data)) {
|
|
171
|
+
for (const item of data) {
|
|
172
|
+
printItem(item);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
printItem(data);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function printItem(item) {
|
|
179
|
+
const parts = [];
|
|
180
|
+
for (const [key, value] of Object.entries(item)) {
|
|
181
|
+
if (value !== void 0 && value !== null) {
|
|
182
|
+
parts.push(`${key}: ${value}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
console.log(parts.join(" | "));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/lib/spotify.ts
|
|
189
|
+
var BASE_URL = "https://api.spotify.com/v1";
|
|
190
|
+
var TOKEN_URL = "https://accounts.spotify.com/api/token";
|
|
191
|
+
async function refreshAccessToken() {
|
|
192
|
+
const config = readConfig();
|
|
193
|
+
if (!config.client_id || !config.refresh_token) {
|
|
194
|
+
throw new Error("Not authenticated. Run: spoti-cli auth --client-id <ID>");
|
|
195
|
+
}
|
|
196
|
+
const res = await fetch(TOKEN_URL, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
199
|
+
body: new URLSearchParams({
|
|
200
|
+
grant_type: "refresh_token",
|
|
201
|
+
refresh_token: config.refresh_token,
|
|
202
|
+
client_id: config.client_id
|
|
203
|
+
})
|
|
204
|
+
});
|
|
205
|
+
if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
updateConfig({
|
|
208
|
+
access_token: data.access_token,
|
|
209
|
+
refresh_token: data.refresh_token ?? config.refresh_token,
|
|
210
|
+
expires_at: Date.now() + data.expires_in * 1e3
|
|
211
|
+
});
|
|
212
|
+
return data.access_token;
|
|
213
|
+
}
|
|
214
|
+
async function getToken() {
|
|
215
|
+
const config = readConfig();
|
|
216
|
+
if (!config.access_token) {
|
|
217
|
+
throw new Error("Not authenticated. Run: spoti-cli auth --client-id <ID>");
|
|
218
|
+
}
|
|
219
|
+
if (config.expires_at && Date.now() > config.expires_at - 6e4) {
|
|
220
|
+
return refreshAccessToken();
|
|
221
|
+
}
|
|
222
|
+
return config.access_token;
|
|
223
|
+
}
|
|
224
|
+
async function spotify(path, options = {}) {
|
|
225
|
+
const token = await getToken();
|
|
226
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
227
|
+
...options,
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Bearer ${token}`,
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
...options.headers
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (res.status === 401) {
|
|
235
|
+
const newToken = await refreshAccessToken();
|
|
236
|
+
const retry = await fetch(`${BASE_URL}${path}`, {
|
|
237
|
+
...options,
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: `Bearer ${newToken}`,
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
...options.headers
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
if (!retry.ok) throw new Error(`Spotify API error: ${retry.status}`);
|
|
245
|
+
return retry.json();
|
|
246
|
+
}
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
const body = await res.text();
|
|
249
|
+
throw new Error(`Spotify API error ${res.status}: ${body}`);
|
|
250
|
+
}
|
|
251
|
+
if (res.status === 204) return {};
|
|
252
|
+
return res.json();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/commands/create.ts
|
|
256
|
+
async function createCommand(name, opts) {
|
|
257
|
+
const user = await spotify("/me");
|
|
258
|
+
const playlist2 = await spotify(
|
|
259
|
+
`/users/${user.id}/playlists`,
|
|
260
|
+
{
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
name,
|
|
264
|
+
description: opts.description ?? "",
|
|
265
|
+
public: opts.public
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
if (opts.tracks) {
|
|
270
|
+
const uris = opts.tracks.split(",").map((t) => t.startsWith("spotify:") ? t : `spotify:track:${t}`);
|
|
271
|
+
await spotify(`/playlists/${playlist2.id}/tracks`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: JSON.stringify({ uris })
|
|
274
|
+
});
|
|
275
|
+
console.log(`Added ${uris.length} tracks`);
|
|
276
|
+
}
|
|
277
|
+
const result = {
|
|
278
|
+
id: playlist2.id,
|
|
279
|
+
name: playlist2.name,
|
|
280
|
+
url: playlist2.external_urls.spotify,
|
|
281
|
+
uri: playlist2.uri
|
|
282
|
+
};
|
|
283
|
+
if (opts.json) {
|
|
284
|
+
output(result, true);
|
|
285
|
+
} else {
|
|
286
|
+
console.log(`Playlist created: ${result.url}`);
|
|
287
|
+
}
|
|
288
|
+
if (opts.open) {
|
|
289
|
+
const { exec } = await import("child_process");
|
|
290
|
+
exec(`open ${playlist2.uri}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/commands/me.ts
|
|
295
|
+
async function meCommand(opts) {
|
|
296
|
+
const user = await spotify("/me");
|
|
297
|
+
const result = {
|
|
298
|
+
id: user.id,
|
|
299
|
+
name: user.display_name,
|
|
300
|
+
email: user.email,
|
|
301
|
+
plan: user.product,
|
|
302
|
+
country: user.country,
|
|
303
|
+
url: user.external_urls.spotify
|
|
304
|
+
};
|
|
305
|
+
output(result, opts.json);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/commands/playlist.ts
|
|
309
|
+
async function playlistListCommand(opts) {
|
|
310
|
+
const data = await spotify(
|
|
311
|
+
`/me/playlists?limit=${opts.limit || "20"}`
|
|
312
|
+
);
|
|
313
|
+
const items = data.items.map((p) => ({
|
|
314
|
+
id: p.id,
|
|
315
|
+
name: p.name,
|
|
316
|
+
tracks: p.tracks.total,
|
|
317
|
+
public: p.public,
|
|
318
|
+
url: p.external_urls.spotify
|
|
319
|
+
}));
|
|
320
|
+
output(items, opts.json);
|
|
321
|
+
}
|
|
322
|
+
async function playlistGetCommand(id, opts) {
|
|
323
|
+
const data = await spotify(`/playlists/${id}`);
|
|
324
|
+
const result = {
|
|
325
|
+
id: data.id,
|
|
326
|
+
name: data.name,
|
|
327
|
+
description: data.description,
|
|
328
|
+
url: data.external_urls.spotify,
|
|
329
|
+
tracks: data.tracks.items.map((i) => ({
|
|
330
|
+
id: i.track.id,
|
|
331
|
+
name: i.track.name,
|
|
332
|
+
artist: i.track.artists.map((a) => a.name).join(", "),
|
|
333
|
+
uri: i.track.uri
|
|
334
|
+
}))
|
|
335
|
+
};
|
|
336
|
+
output(result, opts.json);
|
|
337
|
+
}
|
|
338
|
+
async function playlistAddCommand(id, opts) {
|
|
339
|
+
const uris = opts.tracks.split(",").map((t) => t.startsWith("spotify:") ? t : `spotify:track:${t}`);
|
|
340
|
+
await spotify(`/playlists/${id}/tracks`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
body: JSON.stringify({ uris })
|
|
343
|
+
});
|
|
344
|
+
console.log(`Added ${uris.length} tracks to playlist ${id}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/commands/recommend.ts
|
|
348
|
+
async function recommendCommand(opts) {
|
|
349
|
+
const limit = Number.parseInt(opts.limit || "20");
|
|
350
|
+
const queries = [];
|
|
351
|
+
if (opts.seedGenres) {
|
|
352
|
+
for (const genre of opts.seedGenres.split(",")) {
|
|
353
|
+
queries.push(`genre:${genre.trim()}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (opts.seedArtists) {
|
|
357
|
+
for (const artistId of opts.seedArtists.split(",")) {
|
|
358
|
+
const artist = await spotify(
|
|
359
|
+
`/artists/${artistId.trim()}`
|
|
360
|
+
);
|
|
361
|
+
queries.push(`artist:${artist.name}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (opts.seedTracks) {
|
|
365
|
+
for (const trackId of opts.seedTracks.split(",")) {
|
|
366
|
+
const track = await spotify(
|
|
367
|
+
`/tracks/${trackId.trim()}`
|
|
368
|
+
);
|
|
369
|
+
queries.push(`artist:${track.artists[0].name}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (queries.length === 0) {
|
|
373
|
+
console.error(
|
|
374
|
+
"At least one seed required: --seed-tracks, --seed-artists, or --seed-genres"
|
|
375
|
+
);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
const query = queries.join(" ");
|
|
379
|
+
const params = new URLSearchParams({
|
|
380
|
+
q: query,
|
|
381
|
+
type: "track",
|
|
382
|
+
limit: String(Math.min(limit, 50))
|
|
383
|
+
});
|
|
384
|
+
const data = await spotify(`/search?${params}`);
|
|
385
|
+
if (!data.tracks) {
|
|
386
|
+
console.error("No tracks found");
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
const items = data.tracks.items.map((t) => ({
|
|
390
|
+
id: t.id,
|
|
391
|
+
name: t.name,
|
|
392
|
+
artist: t.artists.map((a) => a.name).join(", "),
|
|
393
|
+
album: t.album.name,
|
|
394
|
+
uri: t.uri,
|
|
395
|
+
popularity: t.popularity
|
|
396
|
+
}));
|
|
397
|
+
output(items, opts.json);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/commands/search.ts
|
|
401
|
+
async function searchCommand(query, opts) {
|
|
402
|
+
const type = opts.type || "track";
|
|
403
|
+
const limit = opts.limit || "10";
|
|
404
|
+
const params = new URLSearchParams({ q: query, type, limit });
|
|
405
|
+
const data = await spotify(`/search?${params}`);
|
|
406
|
+
if (type === "track" && data.tracks) {
|
|
407
|
+
const items = data.tracks.items.map((t) => ({
|
|
408
|
+
id: t.id,
|
|
409
|
+
name: t.name,
|
|
410
|
+
artist: t.artists.map((a) => a.name).join(", "),
|
|
411
|
+
album: t.album.name,
|
|
412
|
+
uri: t.uri,
|
|
413
|
+
popularity: t.popularity
|
|
414
|
+
}));
|
|
415
|
+
output(items, opts.json);
|
|
416
|
+
} else if (type === "artist" && data.artists) {
|
|
417
|
+
const items = data.artists.items.map((a) => ({
|
|
418
|
+
id: a.id,
|
|
419
|
+
name: a.name,
|
|
420
|
+
genres: a.genres.join(", "),
|
|
421
|
+
uri: a.uri,
|
|
422
|
+
popularity: a.popularity
|
|
423
|
+
}));
|
|
424
|
+
output(items, opts.json);
|
|
425
|
+
} else if (type === "album" && data.albums) {
|
|
426
|
+
const items = data.albums.items.map((a) => ({
|
|
427
|
+
id: a.id,
|
|
428
|
+
name: a.name,
|
|
429
|
+
artist: a.artists.map((ar) => ar.name).join(", "),
|
|
430
|
+
released: a.release_date,
|
|
431
|
+
uri: a.uri
|
|
432
|
+
}));
|
|
433
|
+
output(items, opts.json);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/index.ts
|
|
438
|
+
var program = new Command();
|
|
439
|
+
program.name("spoti-cli").description("Spotify Web API from your terminal").version("0.1.0");
|
|
440
|
+
program.command("auth").description("Authenticate with Spotify (OAuth2 PKCE)").option("-c, --client-id <id>", "Spotify app client ID").action((opts) => authCommand(opts.clientId));
|
|
441
|
+
program.command("search <query>").description("Search for tracks, artists, or albums").option("-t, --type <type>", "Search type: track, artist, album", "track").option("-l, --limit <n>", "Number of results", "10").option("--json", "Output as JSON", false).action((query, opts) => searchCommand(query, opts));
|
|
442
|
+
program.command("recommend").description("Get track recommendations based on seeds").option("--seed-tracks <ids>", "Comma-separated track IDs").option("--seed-artists <ids>", "Comma-separated artist IDs").option("--seed-genres <genres>", "Comma-separated genres").option("-l, --limit <n>", "Number of recommendations", "20").option("--energy <n>", "Target energy (0.0-1.0)").option("--danceability <n>", "Target danceability (0.0-1.0)").option("--valence <n>", "Target valence/positiveness (0.0-1.0)").option("--tempo <n>", "Target tempo in BPM").option("--popularity <n>", "Target popularity (0-100)").option("--acousticness <n>", "Target acousticness (0.0-1.0)").option("--instrumentalness <n>", "Target instrumentalness (0.0-1.0)").option("--json", "Output as JSON", false).action((opts) => recommendCommand(opts));
|
|
443
|
+
program.command("create <name>").description("Create a new playlist").option("-d, --description <text>", "Playlist description").option("--public", "Make playlist public", false).option("--tracks <uris>", "Comma-separated track URIs or IDs to add").option("-o, --open", "Open playlist in Spotify app after creation", false).option("--json", "Output as JSON", false).action((name, opts) => createCommand(name, opts));
|
|
444
|
+
program.command("me").description("Show current user profile").option("--json", "Output as JSON", false).action((opts) => meCommand(opts));
|
|
445
|
+
var playlist = program.command("playlist").description("Manage playlists");
|
|
446
|
+
playlist.command("list").description("List your playlists").option("-l, --limit <n>", "Number of playlists", "20").option("--json", "Output as JSON", false).action((opts) => playlistListCommand(opts));
|
|
447
|
+
playlist.command("get <id>").description("Get playlist details and tracks").option("--json", "Output as JSON", false).action((id, opts) => playlistGetCommand(id, opts));
|
|
448
|
+
playlist.command("add <id>").description("Add tracks to a playlist").requiredOption("--tracks <uris>", "Comma-separated track URIs or IDs").action((id, opts) => playlistAddCommand(id, opts));
|
|
449
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crafter/spoti-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Spotify Web API from your terminal. Search, recommend, and create playlists.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"spoti-cli": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"build": "tsup"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"commander": "^13.1.0",
|
|
16
|
+
"open": "^11.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"tsup": "^8.4.0",
|
|
20
|
+
"tsx": "^4.19.0",
|
|
21
|
+
"typescript": "^5.8.0",
|
|
22
|
+
"@types/bun": "^1.2.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"spotify",
|
|
29
|
+
"cli",
|
|
30
|
+
"playlist",
|
|
31
|
+
"music",
|
|
32
|
+
"terminal"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/crafter-station/spoti-cli"
|
|
38
|
+
}
|
|
39
|
+
}
|