diff --git a/README.md b/README.md index 43d7f05..303eeb2 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,22 @@ blackcandy songs list --limit 10 --json blackcandy playlist list --json ``` +## AI Agent Integration + +The repository includes an agent skill with the complete Black Candy CLI workflow at +[`skills/blackcandy/SKILL.md`](skills/blackcandy/SKILL.md). + +The skill is embedded in every binary. Print it for an agent or install it into the +shared agent skills directory: + +```sh +blackcandy skill +blackcandy skill install +``` + +Installation writes `~/.agents/skills/blackcandy/SKILL.md`, which can be discovered +by compatible AI coding agents. + ## Configuration Login stores the server URL, email, and API token in: diff --git a/skills/blackcandy/SKILL.md b/skills/blackcandy/SKILL.md new file mode 100644 index 0000000..2e941cd --- /dev/null +++ b/skills/blackcandy/SKILL.md @@ -0,0 +1,97 @@ +--- +name: blackcandy +description: Interact with a Black Candy music server through the blackcandy CLI. Use for any request to search or browse music, inspect songs and playlists, play audio, manage the playback queue, manage favorite songs, create playlists, add songs to playlists, check server status, or configure Black Candy access. +--- + +# Black Candy CLI + +Use the `blackcandy` command to work with the user's Black Candy music server. + +## Agent rules + +1. Run `blackcandy config` when setup is uncertain. If the server or token is missing, ask the user for the server and email, then run `blackcandy login`; let the password prompt securely rather than putting a password in shell history. +2. Prefer `--json` on read commands so IDs and metadata remain unambiguous. Use the normal table output only for concise user-facing display. +3. Resolve song and playlist IDs with `search`, `songs list`, or `playlist list` before running a mutation. Never guess an ID. +4. Treat `queue clear` as destructive and run it only when the user explicitly requests clearing the queue or confirms the action. +5. `play` produces audible output. Run it only when playback is requested; use `--dry-run` when the user only needs the authenticated stream URL. +6. Never read or expose the stored API token. `blackcandy config` reports whether a token exists without printing it. + +## Workflow + +Check connectivity or diagnose setup: + +```sh +blackcandy config +blackcandy system --json +``` + +Find music before acting on it: + +```sh +blackcandy search "query" --json +blackcandy songs list --limit 20 --json +blackcandy songs show SONG_ID --json +``` + +Then use the returned numeric IDs with the requested action: + +```sh +blackcandy play SONG_ID +blackcandy queue add SONG_ID --last +blackcandy favorite add SONG_ID +blackcandy playlist add-song PLAYLIST_ID SONG_ID +``` + +After a mutation, report the affected song or playlist and the action completed. If a command fails, relay the CLI error and use `blackcandy config`, `blackcandy system`, or `blackcandy --help` to diagnose it instead of guessing. + +## Command reference + +### Setup and server + +```sh +blackcandy login [SERVER] [--email EMAIL] +blackcandy config +blackcandy system [--json] +``` + +`login` prompts for any omitted value. It also accepts `BLACKCANDY_EMAIL` and `BLACKCANDY_PASSWORD`; prefer the password prompt when operating interactively. + +### Search and songs + +```sh +blackcandy search QUERY [--json] +blackcandy songs list [--album-year YEAR] [--album-genre GENRE] \ + [--sort FIELD] [--sort-direction DIRECTION] [--page N] [--limit N] [--json] +blackcandy songs show SONG_ID [--json] +blackcandy play SONG_ID [--dry-run] +``` + +Search returns songs, albums, artists, and playlists. Song list limits must be between 1 and 100. + +### Queue and favorites + +```sh +blackcandy queue list [--json] +blackcandy queue add SONG_ID [--last] +blackcandy queue clear +blackcandy favorite list [--json] +blackcandy favorite add SONG_ID +blackcandy favorite remove SONG_ID +``` + +Without `--last`, `queue add` inserts the song at the start of the queue. + +### Playlists + +```sh +blackcandy playlist list [--json] +blackcandy playlist create NAME +blackcandy playlist songs PLAYLIST_ID [--json] +blackcandy playlist add-song PLAYLIST_ID SONG_ID +``` + +Quote names and search queries containing spaces. + +## Output + +Read commands marked above support `--json` and print the raw server response. Lists are JSON arrays. Mutations print a short confirmation. Use the IDs from command output for follow-up operations. diff --git a/skills/blackcandy/agents/openai.yaml b/skills/blackcandy/agents/openai.yaml new file mode 100644 index 0000000..15488fb --- /dev/null +++ b/skills/blackcandy/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Black Candy" + short_description: "Manage music with the Black Candy CLI" + default_prompt: "Use $blackcandy to find and manage music on my Black Candy server." diff --git a/src/main.rs b/src/main.rs index 1ab444e..3540efa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod config; mod output; mod player; +mod skill; use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand}; @@ -52,6 +53,20 @@ enum Command { Favorite(FavoriteArgs), /// Manage playlists. Playlist(PlaylistArgs), + /// Print or install the embedded AI agent skill. + Skill(SkillArgs), +} + +#[derive(Debug, Args)] +struct SkillArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum SkillCommand { + /// Install the skill for agents that use the shared skills directory. + Install, } #[derive(Debug, Args)] @@ -192,6 +207,7 @@ async fn main() -> Result<()> { Command::Login(args) => login(args).await, Command::Logout => logout().await, Command::Config => show_config(), + Command::Skill(args) => run_skill(args), command => { let config = Config::load()?; let client = configured_client(&config)?; @@ -200,6 +216,17 @@ async fn main() -> Result<()> { } } +fn run_skill(args: SkillArgs) -> Result<()> { + match args.command { + None => print!("{}", skill::CONTENT), + Some(SkillCommand::Install) => { + let path = skill::install()?; + println!("Installed Black Candy skill to {}.", path.display()); + } + } + Ok(()) +} + async fn login(args: LoginArgs) -> Result<()> { let server = match args.server { Some(server) => server, @@ -314,7 +341,7 @@ async fn run_authenticated(command: Command, client: ApiClient) -> Result<()> { Command::Queue(args) => run_queue(args, &client).await?, Command::Favorite(args) => run_favorite(args, &client).await?, Command::Playlist(args) => run_playlist(args, &client).await?, - Command::Login(_) | Command::Logout | Command::Config => { + Command::Login(_) | Command::Logout | Command::Config | Command::Skill(_) => { unreachable!("handled before authentication setup") } } diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..6bd4458 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,44 @@ +use anyhow::{Context, Result}; +use std::{fs, path::PathBuf}; + +pub const CONTENT: &str = include_str!("../skills/blackcandy/SKILL.md"); + +pub fn install() -> Result { + let home = dirs::home_dir().context("could not determine the home directory")?; + install_to(home.join(".agents/skills/blackcandy")) +} + +fn install_to(directory: PathBuf) -> Result { + fs::create_dir_all(&directory) + .with_context(|| format!("failed to create {}", directory.display()))?; + + let path = directory.join("SKILL.md"); + fs::write(&path, CONTENT).with_context(|| format!("failed to write {}", path.display()))?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_skill_has_expected_frontmatter() { + assert!(CONTENT.starts_with("---\nname: blackcandy\n")); + assert!(CONTENT.contains("description:")); + } + + #[test] + fn installs_embedded_skill() { + let root = std::env::temp_dir().join(format!( + "blackcandy-skill-test-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let _ = fs::remove_dir_all(&root); + + let path = install_to(root.clone()).expect("skill should install"); + assert_eq!(fs::read_to_string(path).unwrap(), CONTENT); + + fs::remove_dir_all(root).unwrap(); + } +}