From 17d6c082a94ac965ed00ea38b9e9756fcdd894b4 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:59:36 -0500 Subject: [PATCH 1/3] feat: add music analyze command with presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recoup music analyze — custom prompt mode - recoup music analyze --preset --audio — preset mode - recoup music analyze --preset full_report --audio — full report - recoup music presets — list available presets - --json flag for raw JSON output Tests: 49 passing (10 music tests) Build: ✅ --- __tests__/commands/music.test.ts | 176 +++++++++++++++++++++++++++++++ src/bin.ts | 2 + src/commands/music.ts | 83 +++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 __tests__/commands/music.test.ts create mode 100644 src/commands/music.ts diff --git a/__tests__/commands/music.test.ts b/__tests__/commands/music.test.ts new file mode 100644 index 0000000..2aa6aa3 --- /dev/null +++ b/__tests__/commands/music.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { musicCommand } from "../../src/commands/music.js"; +import { get, post } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("music analyze", () => { + it("sends custom prompt to /api/music/analyze", async () => { + vi.mocked(post).mockResolvedValue({ + status: "success", + response: "This is jazz music.", + elapsed_seconds: 3.2, + }); + + await musicCommand.parseAsync( + ["analyze", "What genre is this?"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/music/analyze", { + prompt: "What genre is this?", + }); + }); + + it("sends preset to /api/music/analyze", async () => { + vi.mocked(post).mockResolvedValue({ + status: "success", + preset: "catalog_metadata", + response: { genre: "pop", tempo_bpm: 120 }, + elapsed_seconds: 8.0, + }); + + await musicCommand.parseAsync( + ["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/music/analyze", { + preset: "catalog_metadata", + audio_url: "https://example.com/song.mp3", + }); + }); + + it("prints text response plainly", async () => { + vi.mocked(post).mockResolvedValue({ + status: "success", + response: "This is a jazz piece in Bb major.", + elapsed_seconds: 2.5, + }); + + await musicCommand.parseAsync( + ["analyze", "Describe this track."], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith("This is a jazz piece in Bb major."); + }); + + it("prints JSON preset response as formatted JSON", async () => { + vi.mocked(post).mockResolvedValue({ + status: "success", + response: { genre: "pop", tempo_bpm: 96 }, + elapsed_seconds: 10.0, + }); + + await musicCommand.parseAsync( + ["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ genre: "pop", tempo_bpm: 96 }, null, 2), + ); + }); + + it("prints full JSON with --json flag", async () => { + const data = { + status: "success", + response: "Jazz in Bb major.", + elapsed_seconds: 3.0, + }; + vi.mocked(post).mockResolvedValue(data); + + await musicCommand.parseAsync( + ["analyze", "Describe this.", "--json"], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); + }); + + it("includes audio_url when --audio is provided", async () => { + vi.mocked(post).mockResolvedValue({ + status: "success", + response: "Upbeat pop.", + elapsed_seconds: 10.0, + }); + + await musicCommand.parseAsync( + ["analyze", "Describe this.", "--audio", "https://example.com/song.mp3"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/music/analyze", { + prompt: "Describe this.", + audio_url: "https://example.com/song.mp3", + }); + }); + + it("prints error on failure", async () => { + vi.mocked(post).mockRejectedValue(new Error("Service Unavailable")); + + await musicCommand.parseAsync( + ["analyze", "Describe this."], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith("Error: Service Unavailable"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exits with error when no prompt or preset given", async () => { + await musicCommand.parseAsync(["analyze"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("music presets", () => { + it("lists presets from /api/music/presets", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + presets: [ + { name: "catalog_metadata", description: "Catalog enrichment", requiresAudio: true, responseFormat: "json" }, + { name: "mood_tags", description: "Mood tags", requiresAudio: true, responseFormat: "json" }, + ], + }); + + await musicCommand.parseAsync(["presets"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/music/presets"); + expect(logSpy).toHaveBeenCalled(); + }); + + it("prints JSON with --json flag", async () => { + const presets = [ + { name: "catalog_metadata", description: "Catalog enrichment" }, + ]; + vi.mocked(get).mockResolvedValue({ status: "success", presets }); + + await musicCommand.parseAsync(["presets", "--json"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(presets, null, 2)); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index 552816a..6f6a7ca 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,6 +3,7 @@ import { whoamiCommand } from "./commands/whoami.js"; import { artistsCommand } from "./commands/artists.js"; import { chatsCommand } from "./commands/chats.js"; import { sandboxesCommand } from "./commands/sandboxes.js"; +import { musicCommand } from "./commands/music.js"; import { orgsCommand } from "./commands/orgs.js"; const program = new Command(); @@ -15,6 +16,7 @@ program program.addCommand(whoamiCommand); program.addCommand(artistsCommand); program.addCommand(chatsCommand); +program.addCommand(musicCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); diff --git a/src/commands/music.ts b/src/commands/music.ts new file mode 100644 index 0000000..3c6316d --- /dev/null +++ b/src/commands/music.ts @@ -0,0 +1,83 @@ +import { Command } from "commander"; +import { get, post } from "../client.js"; +import { printJson, printError } from "../output.js"; + +const analyzeCommand = new Command("analyze") + .description("Analyze music using a preset or custom prompt") + .argument("[prompt]", "Custom text prompt (omit when using --preset)") + .option("--preset ", "Use a curated analysis preset (e.g. catalog_metadata, full_report)") + .option("--audio ", "Public URL to an audio file (MP3, WAV, FLAC)") + .option("--max-tokens ", "Max tokens to generate (default 512)", parseInt) + .option("--json", "Output as JSON") + .action(async (prompt: string | undefined, opts) => { + try { + if (!prompt && !opts.preset) { + console.error("Error: Provide a prompt or use --preset . Run 'recoup music presets' to see available presets."); + process.exit(1); + } + + const body: Record = {}; + if (opts.preset) body.preset = opts.preset; + if (prompt) body.prompt = prompt; + if (opts.audio) body.audio_url = opts.audio; + if (opts.maxTokens) body.max_new_tokens = opts.maxTokens; + + const data = await post("/api/music/analyze", body); + + if (opts.json) { + printJson(data); + } else if (data.report) { + // full_report mode — print each section + const report = data.report as Record; + for (const [key, value] of Object.entries(report)) { + console.log(`\n${"=".repeat(50)}`); + console.log(` ${key.toUpperCase().replace(/_/g, " ")}`); + console.log(`${"=".repeat(50)}`); + if (typeof value === "string") { + console.log(value); + } else { + console.log(JSON.stringify(value, null, 2)); + } + } + console.log(`\n(${data.elapsed_seconds}s total)`); + } else if (typeof data.response === "object") { + // JSON preset — pretty print + console.log(JSON.stringify(data.response, null, 2)); + console.log(`\n(${data.elapsed_seconds}s)`); + } else { + // Text response + console.log(data.response as string); + console.log(`\n(${data.elapsed_seconds}s)`); + } + } catch (err) { + printError((err as Error).message); + } + }); + +const presetsCommand = new Command("presets") + .description("List available analysis presets") + .option("--json", "Output as JSON") + .action(async (opts) => { + try { + const data = await get("/api/music/presets"); + const presets = (data.presets as Record[]) || []; + + if (opts.json) { + printJson(presets); + } else { + for (const p of presets) { + const audio = p.requiresAudio ? " [requires audio]" : ""; + const format = p.responseFormat === "json" ? " (JSON)" : " (text)"; + console.log(` ${p.name}${format}${audio}`); + console.log(` ${p.description}\n`); + } + } + } catch (err) { + printError((err as Error).message); + } + }); + +export const musicCommand = new Command("music") + .description("Music analysis tools") + .addCommand(analyzeCommand) + .addCommand(presetsCommand); From 5d7c646dd2821a7bc18c60431621ab37b64c27f9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:25:39 -0500 Subject: [PATCH 2/3] refactor: use /api/songs/analyze endpoints for music command --- __tests__/commands/music.test.ts | 14 +++++++------- src/commands/music.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/__tests__/commands/music.test.ts b/__tests__/commands/music.test.ts index 2aa6aa3..ceca12d 100644 --- a/__tests__/commands/music.test.ts +++ b/__tests__/commands/music.test.ts @@ -25,7 +25,7 @@ afterEach(() => { }); describe("music analyze", () => { - it("sends custom prompt to /api/music/analyze", async () => { + it("sends custom prompt to /api/songs/analyze", async () => { vi.mocked(post).mockResolvedValue({ status: "success", response: "This is jazz music.", @@ -37,12 +37,12 @@ describe("music analyze", () => { { from: "user" }, ); - expect(post).toHaveBeenCalledWith("/api/music/analyze", { + expect(post).toHaveBeenCalledWith("/api/songs/analyze", { prompt: "What genre is this?", }); }); - it("sends preset to /api/music/analyze", async () => { + it("sends preset to /api/songs/analyze", async () => { vi.mocked(post).mockResolvedValue({ status: "success", preset: "catalog_metadata", @@ -55,7 +55,7 @@ describe("music analyze", () => { { from: "user" }, ); - expect(post).toHaveBeenCalledWith("/api/music/analyze", { + expect(post).toHaveBeenCalledWith("/api/songs/analyze", { preset: "catalog_metadata", audio_url: "https://example.com/song.mp3", }); @@ -121,7 +121,7 @@ describe("music analyze", () => { { from: "user" }, ); - expect(post).toHaveBeenCalledWith("/api/music/analyze", { + expect(post).toHaveBeenCalledWith("/api/songs/analyze", { prompt: "Describe this.", audio_url: "https://example.com/song.mp3", }); @@ -148,7 +148,7 @@ describe("music analyze", () => { }); describe("music presets", () => { - it("lists presets from /api/music/presets", async () => { + it("lists presets from /api/songs/analyze/presets", async () => { vi.mocked(get).mockResolvedValue({ status: "success", presets: [ @@ -159,7 +159,7 @@ describe("music presets", () => { await musicCommand.parseAsync(["presets"], { from: "user" }); - expect(get).toHaveBeenCalledWith("/api/music/presets"); + expect(get).toHaveBeenCalledWith("/api/songs/analyze/presets"); expect(logSpy).toHaveBeenCalled(); }); diff --git a/src/commands/music.ts b/src/commands/music.ts index 3c6316d..feac30c 100644 --- a/src/commands/music.ts +++ b/src/commands/music.ts @@ -22,7 +22,7 @@ const analyzeCommand = new Command("analyze") if (opts.audio) body.audio_url = opts.audio; if (opts.maxTokens) body.max_new_tokens = opts.maxTokens; - const data = await post("/api/music/analyze", body); + const data = await post("/api/songs/analyze", body); if (opts.json) { printJson(data); @@ -59,7 +59,7 @@ const presetsCommand = new Command("presets") .option("--json", "Output as JSON") .action(async (opts) => { try { - const data = await get("/api/music/presets"); + const data = await get("/api/songs/analyze/presets"); const presets = (data.presets as Record[]) || []; if (opts.json) { From 6587a87d92ad5c98223f193195f91fab324e7ca9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:41:27 -0500 Subject: [PATCH 3/3] refactor: rename music CLI primitive to songs --- .../commands/{music.test.ts => songs.test.ts} | 47 +++++-------------- src/bin.ts | 4 +- src/commands/{music.ts => songs.ts} | 14 +++--- 3 files changed, 22 insertions(+), 43 deletions(-) rename __tests__/commands/{music.test.ts => songs.test.ts} (76%) rename src/commands/{music.ts => songs.ts} (86%) diff --git a/__tests__/commands/music.test.ts b/__tests__/commands/songs.test.ts similarity index 76% rename from __tests__/commands/music.test.ts rename to __tests__/commands/songs.test.ts index ceca12d..c8cfa3f 100644 --- a/__tests__/commands/music.test.ts +++ b/__tests__/commands/songs.test.ts @@ -5,7 +5,7 @@ vi.mock("../../src/client.js", () => ({ post: vi.fn(), })); -import { musicCommand } from "../../src/commands/music.js"; +import { songsCommand } from "../../src/commands/songs.js"; import { get, post } from "../../src/client.js"; let logSpy: ReturnType; @@ -24,7 +24,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("music analyze", () => { +describe("songs analyze", () => { it("sends custom prompt to /api/songs/analyze", async () => { vi.mocked(post).mockResolvedValue({ status: "success", @@ -32,10 +32,7 @@ describe("music analyze", () => { elapsed_seconds: 3.2, }); - await musicCommand.parseAsync( - ["analyze", "What genre is this?"], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--prompt", "What genre is this?"], { from: "user" }); expect(post).toHaveBeenCalledWith("/api/songs/analyze", { prompt: "What genre is this?", @@ -50,10 +47,7 @@ describe("music analyze", () => { elapsed_seconds: 8.0, }); - await musicCommand.parseAsync( - ["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], { from: "user" }); expect(post).toHaveBeenCalledWith("/api/songs/analyze", { preset: "catalog_metadata", @@ -68,10 +62,7 @@ describe("music analyze", () => { elapsed_seconds: 2.5, }); - await musicCommand.parseAsync( - ["analyze", "Describe this track."], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--prompt", "Describe this track."], { from: "user" }); expect(logSpy).toHaveBeenCalledWith("This is a jazz piece in Bb major."); }); @@ -83,10 +74,7 @@ describe("music analyze", () => { elapsed_seconds: 10.0, }); - await musicCommand.parseAsync( - ["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], { from: "user" }); expect(logSpy).toHaveBeenCalledWith( JSON.stringify({ genre: "pop", tempo_bpm: 96 }, null, 2), @@ -101,10 +89,7 @@ describe("music analyze", () => { }; vi.mocked(post).mockResolvedValue(data); - await musicCommand.parseAsync( - ["analyze", "Describe this.", "--json"], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--prompt", "Describe this.", "--json"], { from: "user" }); expect(logSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); }); @@ -116,10 +101,7 @@ describe("music analyze", () => { elapsed_seconds: 10.0, }); - await musicCommand.parseAsync( - ["analyze", "Describe this.", "--audio", "https://example.com/song.mp3"], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--prompt", "Describe this.", "--audio", "https://example.com/song.mp3"], { from: "user" }); expect(post).toHaveBeenCalledWith("/api/songs/analyze", { prompt: "Describe this.", @@ -130,24 +112,21 @@ describe("music analyze", () => { it("prints error on failure", async () => { vi.mocked(post).mockRejectedValue(new Error("Service Unavailable")); - await musicCommand.parseAsync( - ["analyze", "Describe this."], - { from: "user" }, - ); + await songsCommand.parseAsync(["analyze", "--prompt", "Describe this."], { from: "user" }); expect(errorSpy).toHaveBeenCalledWith("Error: Service Unavailable"); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits with error when no prompt or preset given", async () => { - await musicCommand.parseAsync(["analyze"], { from: "user" }); + await songsCommand.parseAsync(["analyze"], { from: "user" }); expect(errorSpy).toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(1); }); }); -describe("music presets", () => { +describe("songs presets", () => { it("lists presets from /api/songs/analyze/presets", async () => { vi.mocked(get).mockResolvedValue({ status: "success", @@ -157,7 +136,7 @@ describe("music presets", () => { ], }); - await musicCommand.parseAsync(["presets"], { from: "user" }); + await songsCommand.parseAsync(["presets"], { from: "user" }); expect(get).toHaveBeenCalledWith("/api/songs/analyze/presets"); expect(logSpy).toHaveBeenCalled(); @@ -169,7 +148,7 @@ describe("music presets", () => { ]; vi.mocked(get).mockResolvedValue({ status: "success", presets }); - await musicCommand.parseAsync(["presets", "--json"], { from: "user" }); + await songsCommand.parseAsync(["presets", "--json"], { from: "user" }); expect(logSpy).toHaveBeenCalledWith(JSON.stringify(presets, null, 2)); }); diff --git a/src/bin.ts b/src/bin.ts index 5b752cf..f687860 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -5,7 +5,7 @@ import { whoamiCommand } from "./commands/whoami.js"; import { artistsCommand } from "./commands/artists.js"; import { chatsCommand } from "./commands/chats.js"; import { sandboxesCommand } from "./commands/sandboxes.js"; -import { musicCommand } from "./commands/music.js"; +import { songsCommand } from "./commands/songs.js"; import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; @@ -22,7 +22,7 @@ program program.addCommand(whoamiCommand); program.addCommand(artistsCommand); program.addCommand(chatsCommand); -program.addCommand(musicCommand); +program.addCommand(songsCommand); program.addCommand(notificationsCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); diff --git a/src/commands/music.ts b/src/commands/songs.ts similarity index 86% rename from src/commands/music.ts rename to src/commands/songs.ts index feac30c..6f48f5d 100644 --- a/src/commands/music.ts +++ b/src/commands/songs.ts @@ -4,21 +4,21 @@ import { printJson, printError } from "../output.js"; const analyzeCommand = new Command("analyze") .description("Analyze music using a preset or custom prompt") - .argument("[prompt]", "Custom text prompt (omit when using --preset)") + .option("--prompt ", "Custom text prompt (omit when using --preset)") .option("--preset ", "Use a curated analysis preset (e.g. catalog_metadata, full_report)") .option("--audio ", "Public URL to an audio file (MP3, WAV, FLAC)") .option("--max-tokens ", "Max tokens to generate (default 512)", parseInt) .option("--json", "Output as JSON") - .action(async (prompt: string | undefined, opts) => { + .action(async (opts) => { try { - if (!prompt && !opts.preset) { - console.error("Error: Provide a prompt or use --preset . Run 'recoup music presets' to see available presets."); + if (!opts.prompt && !opts.preset) { + console.error("Error: Provide --prompt or use --preset . Run 'recoup songs presets' to see available presets."); process.exit(1); } const body: Record = {}; if (opts.preset) body.preset = opts.preset; - if (prompt) body.prompt = prompt; + if (opts.prompt) body.prompt = opts.prompt; if (opts.audio) body.audio_url = opts.audio; if (opts.maxTokens) body.max_new_tokens = opts.maxTokens; @@ -77,7 +77,7 @@ const presetsCommand = new Command("presets") } }); -export const musicCommand = new Command("music") - .description("Music analysis tools") +export const songsCommand = new Command("songs") + .description("Song analysis tools") .addCommand(analyzeCommand) .addCommand(presetsCommand);