diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md new file mode 100644 index 0000000..d70f6f7 --- /dev/null +++ b/soulseek-rs/src/SKILL.md @@ -0,0 +1,137 @@ +--- +name: soulseek-rs +description: >- + Search for, download, and share files on the Soulseek peer-to-peer network + using the soulseek-rs command-line client. Use when the user wants to find or + fetch music or other files from Soulseek, list what a peer shares, check + whether a peer is online, read or post in Soulseek chat rooms, send private + messages, or stay online serving uploads. +--- + +# soulseek-rs + +A Soulseek client whose subcommands are built to be driven by a program. Run +`soulseek-rs` with no subcommand and it launches an interactive terminal UI — +never do that; it needs a real terminal and never returns. + +## The contract + +Always pass `--json`. It is a global flag, so it works on every subcommand and +in any position. + +- **stdout is data.** One JSON object per line, and nothing else ever. +- **stderr is progress.** Narration and warnings. Never parse it. `--quiet` + silences it. +- **The exit code is the verdict.** Branch on it, not on the text. + +Without `--json` the same records print as tab-separated fields. Do not parse +that form; the columns are a subset of the JSON keys. + +## Exit codes + +| Code | Meaning | What to do | +| ---- | ------- | ---------- | +| 0 | Success | — | +| 1 | Unexpected failure | Report it; do not retry blindly | +| 2 | Bad arguments, missing credentials, unusable config | Fix the invocation; run `whoami` to check credentials | +| 3 | Could not reach the server, or the login was rejected | Network or password problem, not a query problem | +| 4 | It worked and found nothing | Widen or correct the query — retrying it verbatim will not help | +| 5 | Gave up waiting | Raise `--timeout`, or `--search-timeout` for searches | +| 6 | A transfer started and did not finish | The peer stalled; try another result | + +## Commands + +Each entry lists the keys of the objects it prints. + +**`search `** — one object per matching file: +`user`, `path`, `size`, `bitrate`, `duration`, `free_slot`, `slots`, `speed`. +Flags: `--min-bitrate `, `--free-slots`, `-n/--limit` (0 means all), +`--sort best|size|bitrate|speed`. Collects results for the whole +`--search-timeout` window before printing anything. + +**`download [--size N]`** or **`download --stdin`** — one object +per finished transfer: `user`, `path`, `size`, `file` (the local path written). +`--stdin` consumes `search --json` output as-is. + +**`get `** — search, pick, and download in one step. `--pick +best|first|all`, `-n/--limit`, `--min-bitrate`, `--free-slots`. Prefer this +over `search` + `download` when the user just wants the file. + +**`browse `** — one object per file the peer shares: `user`, `directory`, +`path`, `size`. + +**`user `** — one object: `user`, `status` (`online`/`away`/`offline`), +`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may +be `null` when the server has not answered that half — `null` means unknown, +not offline. + +**`whoami`** — one object: `user`, `server`, `listening`, `listen_port`, +`shared_folders`, `shared_files`, `download_dir`. The cheapest way to prove the +credentials and connection work. + +**`room list`** — `room`, `users`. +**`room users `** — `room`, `user`. +**`room listen `** — streams `type`, `room`, `user`, `message`. +**`room say `** — prints nothing; exit 0 is the confirmation. + +**`message read`** — streams `timestamp`, `user`, `message`. +**`message send `** — prints nothing; exit 0 is the confirmation. + +**`serve`** — stay online sharing files, one object per upload change: `user`, +`path`, `size`, `bytes_sent`, `speed`, `status` +(`queued`/`uploading`/`completed`/`cancelled`/`failed`), `reason`. + +**`shares list`** — one object per configured folder: `directory`, `usable`. +**`shares add `** and **`shares remove `** — change the +config file; they print nothing. +**`shares status`** and **`shares reindex`** — `folders`, `files`, +`directories`, counted by logging in and scanning. + +**`config path`**, **`config list`**, **`config get `**, and +**`config set `** — `key`, `value`. + +**`portmap`** — `ok`, `backend`, `external`, `port`. Exit 4 when no router +answered, which is a negative answer rather than an error. + +**`skills install`**, **`skills uninstall`**, and **`skills list`** — write +this skill file into the local agent directories, or remove it: `agent`, +`path`, `action`. + +## Idioms + +```bash +# One shot: find it, pick the best, fetch it. +soulseek-rs get 'aphex twin xtal' --json + +# Filter precisely, then fetch what survived. +soulseek-rs search 'rjd2' --json \ + | jq -c 'select(.bitrate >= 320 and .free_slot)' \ + | soulseek-rs download --stdin --json + +# Take one folder from a peer. +soulseek-rs browse someuser --json \ + | jq -c 'select(.path | endswith(".flac"))' \ + | soulseek-rs download --stdin --json + +# Is this peer worth queueing behind? +soulseek-rs user someuser --json +``` + +`download --stdin` reads whole records, so filtering with `jq` and piping back +is the normal way to be selective. + +## Rules + +- **Remote paths belong to the peer.** They are backslash-separated and may + contain anything. Pass them back byte-identical — never normalise, unescape, + or let a shell expand them. +- **`--follow` never returns.** For `room listen`, `message read`, and `serve`, + always pass `--duration ` instead. +- **Every wait is bounded.** `--timeout` per command, `--search-timeout` for + searches. There is no unbounded form. +- **Exit 4 is not a failure.** A search that finds nothing means the query was + too narrow or misspelled. Widen it; use `whoami` to rule out the connection. +- **Never put a password in argv** — `ps` shows it to every user on the box. + Use `--password-stdin`, the OS keychain, or `SOULSEEK_PASSWORD_CMD`. +- **Downloading pulls a file off a stranger's machine.** Confirm with the user + before `get --pick all` or any fetch whose size is not known up front. diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index d89aed0..f8c00d7 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -26,6 +26,7 @@ Examples: soulseek-rs room listen lobby --follow stream room chat soulseek-rs serve --follow --json share files, stream upload events soulseek-rs user someuser is this peer online and worth it + soulseek-rs skills install teach your coding agent this CLI Exit codes: 0 success 2 usage/config 3 connect or login 4 no results @@ -193,6 +194,32 @@ pub enum Commands { /// Check whether the router opens the listen port (UPnP/NAT-PMP) Portmap, + + /// Teach a local coding agent this command surface + #[command(subcommand)] + Skills(SkillsCommand), +} + +#[derive(Subcommand, Debug)] +pub enum SkillsCommand { + /// Write the skill into every agent found on this machine, replacing an + /// older copy + Install(SkillsArgs), + + /// Remove the skill again + Uninstall(SkillsArgs), + + /// Show where the skill goes and what is there now + List(SkillsArgs), +} + +#[derive(Args, Debug)] +pub struct SkillsArgs { + /// Skills directory to use instead of the agents found on this machine; + /// repeat for several. Use `--dir .claude/skills` to commit the skill + /// alongside a project + #[arg(long, value_name = "PATH", action = ArgAction::Append)] + pub dir: Vec, } #[derive(Args, Debug)] @@ -588,6 +615,24 @@ mod tests { )); } + #[test] + fn skills_dir_repeats_into_a_list() { + let cli = parse(&[ + "soulseek-rs", + "skills", + "install", + "--dir", + "/a", + "--dir", + "/b", + ]); + let Some(Commands::Skills(SkillsCommand::Install(args))) = cli.command + else { + panic!("expected skills install"); + }; + assert_eq!(args.dir, [PathBuf::from("/a"), PathBuf::from("/b")]); + } + #[test] fn get_defaults_to_picking_the_best_result() { let cli = parse(&["soulseek-rs", "get", "query"]); diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index cce4ce3..7e98ff9 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -1,10 +1,13 @@ //! One-shot commands: everything the binary can do without a terminal. //! -//! Each command opens a [`Session`], does one job, writes records to stdout -//! and progress to stderr, and returns an exit code through [`CliError`]. +//! Each command does one job, writes records to stdout and progress to stderr, +//! and returns an exit code through [`CliError`]. Most open a [`Session`] +//! first; the ones that only read the config file or the local machine are +//! dispatched by `main::run` before any credentials are demanded. pub mod peer; pub mod settings; +pub mod skills; pub mod social; pub mod transfer; @@ -193,7 +196,7 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult { // These need no account, so main.rs answers them before asking for // credentials; the arms exist only to keep the match exhaustive. Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port), - Commands::Config(_) | Commands::Shares(_) => { + Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) => { Err(CliError::usage("handled without a session; see main::run")) } } diff --git a/soulseek-rs/src/commands/skills.rs b/soulseek-rs/src/commands/skills.rs new file mode 100644 index 0000000..9d9794f --- /dev/null +++ b/soulseek-rs/src/commands/skills.rs @@ -0,0 +1,253 @@ +//! Teaching a local coding agent this command surface: `skills`. +//! +//! A `cargo install` leaves a binary and nothing else, so the skill travels +//! inside it and is written out on demand. Installing over an older copy is +//! what updating means, which is why there is no separate verb for it. + +use crate::cli::{SkillsArgs, SkillsCommand}; +use crate::output::{CliError, CliResult, Out, SkillRecord}; +use directories::UserDirs; +use std::path::{Path, PathBuf}; + +/// The skill as shipped, and what the drift test holds the CLI to. +const SKILL: &str = include_str!("../SKILL.md"); + +/// The line `uninstall` requires before deleting anything. +const FRONTMATTER_NAME: &str = "name: soulseek-rs"; + +/// A skills directory to write into, labelled by whose it is. +struct Target { + agent: &'static str, + skills: PathBuf, +} + +impl Target { + fn dir(&self) -> PathBuf { + self.skills.join("soulseek-rs") + } + + fn file(&self) -> PathBuf { + self.dir().join("SKILL.md") + } + + fn record(&self, action: &str) -> SkillRecord { + SkillRecord { + agent: self.agent.to_string(), + path: self.file().display().to_string(), + action: action.to_string(), + } + } +} + +pub fn run(out: &Out, command: &SkillsCommand) -> CliResult { + match command { + SkillsCommand::Install(args) => install(out, &targets(args)?), + SkillsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + SkillsCommand::List(args) => { + list(out, &targets(args)?); + Ok(()) + } + } +} + +fn install(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let file = target.file(); + let action = if current(&file) { + "unchanged" + } else { + let dir = target.dir(); + std::fs::create_dir_all(&dir).map_err(|e| unwritable(&dir, &e))?; + std::fs::write(&file, SKILL).map_err(|e| unwritable(&file, &e))?; + "installed" + }; + out.emit(&target.record(action)); + } + out.status("restart your agent, or start a new session, to pick it up"); + Ok(()) +} + +/// Delete only what this command wrote. A mistyped `--dir` names somebody +/// else's directory far more often than it names ours, so the skill file has +/// to identify itself first, and only that file goes: a folder holding +/// anything else survives the empty-directory sweep that follows. +fn uninstall(out: &Out, targets: &[Target]) -> CliResult { + for target in targets { + let file = target.file(); + let action = if ours(&file) { + std::fs::remove_file(&file).map_err(|e| unwritable(&file, &e))?; + let _ = std::fs::remove_dir(target.dir()); + "removed" + } else { + "absent" + }; + out.emit(&target.record(action)); + } + Ok(()) +} + +fn list(out: &Out, targets: &[Target]) { + for target in targets { + let action = match std::fs::read_to_string(target.file()) { + Ok(found) if found == SKILL => "unchanged", + Ok(_) => "outdated", + Err(_) => "absent", + }; + out.emit(&target.record(action)); + } +} + +/// Where to write. Naming a directory aims the command at exactly that +/// directory: a run that says where to go should not also touch every agent it +/// happens to find. Detection is what answers the question when nothing does. +fn targets(args: &SkillsArgs) -> CliResult> { + let chosen: Vec = args + .dir + .iter() + .map(|dir| Target { + agent: "custom", + skills: dir.clone(), + }) + .collect(); + if !chosen.is_empty() { + return Ok(chosen); + } + + let home = UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .ok_or_else(|| { + CliError::usage( + "cannot find your home directory; name a skills directory \ + with --dir", + ) + })?; + + let found = detected(&home, &config_home(&home)); + if found.is_empty() { + return Err(CliError::no_results( + "no coding agent found in your home directory; name a skills \ + directory with --dir", + )); + } + Ok(found) +} + +/// The agents that read a plain `SKILL.md`, kept to the ones actually on this +/// machine: an agent counts as present when its own directory is, not when it +/// already has a skills folder. +fn detected(home: &Path, config_home: &Path) -> Vec { + [ + ("claude", home.join(".claude"), "skills"), + ("opencode", config_home.join("opencode"), "skill"), + ] + .into_iter() + .filter(|(_, agent_dir, _)| agent_dir.is_dir()) + .map(|(agent, agent_dir, skills)| Target { + agent, + skills: agent_dir.join(skills), + }) + .collect() +} + +/// Where an agent keeps its own configuration, which is the XDG location on +/// every platform — unlike this client's config, which follows each platform's +/// own convention (see `persist::paths`). +fn config_home(home: &Path) -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|dir| !dir.is_empty()) + .map_or_else(|| home.join(".config"), PathBuf::from) +} + +fn current(file: &Path) -> bool { + std::fs::read_to_string(file).is_ok_and(|found| found == SKILL) +} + +fn ours(file: &Path) -> bool { + std::fs::read_to_string(file) + .is_ok_and(|found| found.contains(FRONTMATTER_NAME)) +} + +fn unwritable(path: &Path, error: &std::io::Error) -> CliError { + CliError::usage(format!("cannot write {}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use clap::CommandFactory; + + /// Every command this binary answers, spelled the way a user types it. + /// Groups contribute their verbs rather than themselves — nobody runs a + /// bare `room`. + fn command_paths(command: &clap::Command, prefix: &str) -> Vec { + let mut paths = Vec::new(); + for sub in command.get_subcommands() { + if sub.get_name() == "help" { + continue; + } + let path = format!("{prefix}{}", sub.get_name()); + if sub.get_subcommands().next().is_some() { + paths.extend(command_paths(sub, &format!("{path} "))); + } else { + paths.push(path); + } + } + paths + } + + /// The skill is only worth shipping while it still describes the binary, + /// so a new command fails the build until it is documented. + /// + /// Looking for the name alone would pass on coincidence: half of these are + /// also JSON keys the document lists, and a command named in passing in the + /// prose would satisfy it too. What counts is an entry in the command + /// section, which is the only place written as a bold quoted invocation. + #[test] + fn the_skill_documents_every_command() { + let paths = command_paths(&Cli::command(), ""); + assert!(paths.len() > 10, "expected the full command surface"); + for path in paths { + assert!( + SKILL.contains(&format!("**`{path}`")) + || SKILL.contains(&format!("**`{path} ")), + "SKILL.md has no `{path}` entry (expected **`{path} …`**)" + ); + } + } + + #[test] + fn the_skill_tells_agents_to_ask_for_json() { + assert!(SKILL.contains("--json")); + assert!(SKILL.contains(FRONTMATTER_NAME)); + } + + #[test] + fn only_agents_that_are_installed_become_targets() { + let home = tempfile::tempdir().expect("tempdir"); + let config = home.path().join(".config"); + assert!(detected(home.path(), &config).is_empty()); + + std::fs::create_dir_all(home.path().join(".claude")).expect("claude"); + std::fs::create_dir_all(config.join("opencode")).expect("opencode"); + let found = detected(home.path(), &config); + + assert_eq!(found.len(), 2); + assert_eq!(found[0].skills, home.path().join(".claude/skills")); + assert_eq!(found[1].skills, config.join("opencode/skill")); + } + + #[test] + fn a_file_we_did_not_write_is_never_ours_to_delete() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("SKILL.md"); + std::fs::write(&file, "---\nname: something-else\n---\n").expect("w"); + + assert!(!ours(&file)); + assert!(!ours(&dir.path().join("missing.md"))); + + std::fs::write(&file, SKILL).expect("write"); + assert!(ours(&file)); + assert!(current(&file)); + } +} diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 18fc9f8..e26fa12 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -61,6 +61,9 @@ fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Portmap => { return commands::portmap(out, resolved.listener_port); } + Commands::Skills(ref command) => { + return commands::skills::run(out, command); + } Commands::Config(ConfigCommand::Path) => { return commands::settings::config_path(out, &store); } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index 0a0e1f8..813985b 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -423,6 +423,28 @@ impl Record for ConfigRecord { } } +/// What happened to one agent's copy of the skill. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkillRecord { + /// The agent the directory belongs to, or `custom` for a `--dir`. + pub agent: String, + pub path: String, + /// `installed` (written), `unchanged` (already identical), `outdated` + /// (present but different, reported by `list`), `removed`, or `absent`. + pub action: String, +} + +impl Record for SkillRecord { + fn text(&self) -> String { + format!( + "{}\t{}\t{}", + sanitize(&self.action), + sanitize(&self.agent), + sanitize(&self.path) + ) + } +} + /// The result of probing automatic port mapping. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PortmapRecord { diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index de1a8ab..a3032c0 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -94,6 +94,7 @@ fn help_lists_every_scriptable_command() { let help = stdout(&output); for command in [ "search", "download", "get", "browse", "room", "message", "portmap", + "skills", ] { assert!(help.contains(command), "--help should mention {command}"); } @@ -256,6 +257,82 @@ fn quiet_keeps_progress_off_stderr() { ); } +/// What the first record says happened, parsed rather than matched as text. +fn action(output: &Output) -> String { + let record: serde_json::Value = + serde_json::from_str(records(output).first().expect("one record")) + .expect("valid JSON"); + record["action"] + .as_str() + .expect("a record carries an action") + .to_string() +} + +#[test] +fn installing_the_skill_is_repeatable_and_reversible() { + let target = Scratch::new("skills"); + let dir = target.display(); + let installed = target.path().join("soulseek-rs").join("SKILL.md"); + + let first = run(&["--json", "skills", "install", "--dir", &dir]); + assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first)); + assert_eq!(action(&first), "installed"); + let written = std::fs::read_to_string(&installed).expect("a skill file"); + assert!(written.contains("name: soulseek-rs")); + assert!(written.contains("--json")); + + let second = run(&["--json", "skills", "install", "--dir", &dir]); + assert_eq!( + action(&second), + "unchanged", + "reinstalling is how you update" + ); + + // A neighbour in the same folder proves the sweep removes our file rather + // than the directory it happened to be sitting in. + let neighbour = installed.with_file_name("notes.md"); + std::fs::write(&neighbour, "mine").expect("a neighbouring file"); + + let removed = run(&["--json", "skills", "uninstall", "--dir", &dir]); + assert_eq!(action(&removed), "removed"); + assert!(!installed.exists()); + assert!(neighbour.exists(), "a file we did not write must survive"); + + let again = run(&["--json", "skills", "uninstall", "--dir", &dir]); + assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure"); + assert_eq!(action(&again), "absent"); +} + +/// A mistyped `--dir` names somebody else's directory far more often than it +/// names one of ours, so uninstall has to leave it standing. +#[test] +fn uninstall_leaves_a_skill_it_did_not_write() { + let target = Scratch::new("skills-foreign"); + let foreign = target.path().join("soulseek-rs").join("SKILL.md"); + std::fs::create_dir_all(foreign.parent().expect("a parent")) + .expect("mkdir"); + std::fs::write(&foreign, "---\nname: someone-else\n---\n").expect("write"); + + let output = + run(&["--json", "skills", "uninstall", "--dir", &target.display()]); + assert_eq!(code(&output), EXIT_OK); + assert_eq!(action(&output), "absent"); + assert!(foreign.exists(), "a foreign skill must survive"); +} + +#[test] +fn listing_skills_reports_without_writing_anything() { + let target = Scratch::new("skills-list"); + + let output = run(&["--json", "skills", "list", "--dir", &target.display()]); + assert_eq!(code(&output), EXIT_OK); + assert_eq!(action(&output), "absent"); + assert!( + !target.path().join("soulseek-rs").exists(), + "list must not create anything" + ); +} + // --------------------------------------------------------------------------- // Server required (soulfind) // ---------------------------------------------------------------------------