Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions skills/blackcandy/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions skills/blackcandy/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -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."
29 changes: 28 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod api;
mod config;
mod output;
mod player;
mod skill;

use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
Expand Down Expand Up @@ -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<SkillCommand>,
}

#[derive(Debug, Subcommand)]
enum SkillCommand {
/// Install the skill for agents that use the shared skills directory.
Install,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -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)?;
Expand All @@ -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,
Expand Down Expand Up @@ -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")
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/skill.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<PathBuf> {
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();
}
}