docs: document the skills command and the scriptable surface

The READMEs still described the flags and spellings the one-shot rework
replaced, and said nothing about `skills`. The root README gains an agent
skills section covering install, update and removal; the crate READMEs catch
up with the current command set, record shapes and library API.
This commit is contained in:
Michel de Graaf
2026-07-25 02:32:05 +02:00
parent cd41688803
commit 14b0321dbc
3 changed files with 207 additions and 44 deletions
+105 -27
View File
@@ -10,10 +10,10 @@ music enthusiasts around the world to share niche music.
## Features ## Features
- **Search & download** — search the network, pick results in the TUI, and - **Search & download** — search the network and queue downloads in the TUI,
queue downloads with pause, resume, and retry or fetch a track in one command with `get`
- **Sharing** — point `--shared-dir` at a directory and your files show up in - **Sharing** — `shares add` a directory and your files show up in searches;
searches; peers can browse and download them `serve` stays online so peers can browse and download them
- **Browse** — list any user's shared files and download straight from the tree - **Browse** — list any user's shared files and download straight from the tree
- **Chat rooms** — list, join, and talk in public rooms, several open at once - **Chat rooms** — list, join, and talk in public rooms, several open at once
- **Private messages** — send and receive messages, with an inbox in the TUI - **Private messages** — send and receive messages, with an inbox in the TUI
@@ -136,16 +136,25 @@ soulseek-rs user <NAME> # a peer's status and share counts
soulseek-rs shares list|add|remove|status|reindex soulseek-rs shares list|add|remove|status|reindex
soulseek-rs config path|list|get|set soulseek-rs config path|list|get|set
soulseek-rs portmap # test automatic port mapping soulseek-rs portmap # test automatic port mapping
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
``` ```
`whoami`, `config` and `shares list|add|remove` are the ones a script runs `whoami`, `config` and `shares list|add|remove` are the ones a script runs
first: the config commands need no account at all, and `whoami` answers first: the config commands need no account at all, and `whoami` answers
"are these credentials good and what am I offering" in one call. "are these credentials good and what am I offering" in one call.
Every command emits records except the two that perform an action — Eleven of these need no credentials, because they never touch the network:
`room say` and `message send` say nothing and answer with their exit code `config path|list|get|set`, `shares list|add|remove`, `portmap` and
alone. `room listen` and `message read` stream records as they arrive, until `skills install|uninstall|list`. Note that
`--duration` seconds pass or, with `--follow`, until they are interrupted. `shares status` and `shares reindex` are not among them — they report what the
network will actually see, which means logging in and letting the library scan
the folders.
Every command emits records except the four that perform an action —
`room say`, `message send`, `shares add` and `shares remove` say nothing on
stdout and answer with their exit code alone. `room listen`, `message read`
and `serve` stream records as they arrive, until `--duration` seconds pass or,
with `--follow`, until they are interrupted.
#### Record shapes #### Record shapes
@@ -154,12 +163,37 @@ a path may contain anything except a tab (tabs and newlines in peer-supplied
text are replaced with spaces so a record is always exactly one line): text are replaced with spaces so a record is always exactly one line):
``` ```
search user size bitrate path search user size bitrate path
browse user size path browse user size path
room users room download / get local-path
download local-path room list users room
room users user
room listen type room user message
message read timestamp user message
serve status user sent/size path
user user status average-speed shared-files
whoami user server shared-folders shared-files
shares list ok|missing directory
shares status folders files
config get|set|list|path key value
portmap ok|failed backend external
``` ```
`room listen`'s `type` is `message`, `join` or `leave`; a join or leave leaves
the message field empty. `serve`'s `status` is `uploading`, `completed`,
`cancelled` or `failed`. `user`'s `status` is `online`, `away` or `offline`,
and any field the server did not answer prints as `-`.
`--json` carries more than text does, because an object has room for fields a
column layout does not: `search` adds `duration`, `slots`, `speed` and
`free_slot`; `browse` adds `directory`; `download` adds the `user`, remote
`path` and `size` alongside the local `file`; `whoami` adds `listening`,
`listen_port` and `download_dir`; `user` adds `privileged` and
`shared_folders`; `shares status` adds the `directories` array; `serve` adds
`bytes_sent`, `size`, `speed` and a `reason` on failure; `portmap` adds
`port`. Fields the server never answered are `null` rather than absent, so a
missing reply cannot be misread as a zero.
`download --stdin` reads back either shape it emits — a JSON object per line, `download --stdin` reads back either shape it emits — a JSON object per line,
or tab-separated text whose first field is the user and last field is the or tab-separated text whose first field is the user and last field is the
path — so `search` and `browse` pipe straight into it. path — so `search` and `browse` pipe straight into it.
@@ -199,13 +233,46 @@ soulseek-rs serve --follow --json | tee -a uploads.ndjson
soulseek-rs user someuser --json | jq -e '.status != "offline"' soulseek-rs user someuser --json | jq -e '.status != "offline"'
``` ```
### Agent skills
The scriptable surface above is meant to be driven by coding agents as much as
by shell scripts, but `--help` cannot tell an agent which JSON keys a record
carries or what exit 4 means. That lives in a skill file shipped inside the
binary, so a `cargo install` is all you need to hand it to your agent:
```bash
soulseek-rs skills install
```
That writes `SKILL.md` into every agent it finds in your home directory —
currently Claude Code (`~/.claude/skills`) and opencode
(`~/.config/opencode/skill`) — and prints one record per target:
```
installed claude /home/you/.claude/skills/soulseek-rs/SKILL.md
unchanged opencode /home/you/.config/opencode/skill/soulseek-rs/SKILL.md
```
Running it again is how you update after upgrading the binary; an identical
copy reports `unchanged` rather than rewriting it. `skills list` shows what is
there without touching anything, and `skills uninstall` removes it again —
only when the file it finds is the one this binary wrote.
For an agent this table does not cover, name the directory yourself, or commit
the skill next to a project so everyone working on it gets the same brief:
```bash
soulseek-rs skills install --dir ~/.config/some-agent/skills
soulseek-rs skills install --dir .claude/skills # commit it with the repo
```
### Sharing ### Sharing
`serve` is the mode that makes this client a source: it stays logged in, keeps `serve` is the mode that makes this client a source: it stays logged in, keeps
the listener and the share index alive, answers searches and browse requests the listener and the share index alive, answers searches and browse requests
from the network, and prints one record per upload as it changes state from the network, and prints one record per upload as it changes state
(`uploading`, `completed`, `cancelled`, `failed`). It ends after `--duration` (`uploading`, `completed`, `cancelled`, `failed`). It ends after `--duration`
seconds, or runs until interrupted with `--follow`. seconds (an hour by default), or runs until interrupted with `--follow`.
```bash ```bash
soulseek-rs shares add ~/Music # remembered in config.toml soulseek-rs shares add ~/Music # remembered in config.toml
@@ -228,26 +295,37 @@ Downloads run under a deadline (`--timeout`, 300s by default) and
Every setting can come from a flag, an environment variable, or the config Every setting can come from a flag, an environment variable, or the config
file, in that order of precedence. Flags work before or after the subcommand. file, in that order of precedence. Flags work before or after the subcommand.
| Flag | Environment variable | `config.toml` key | | Flag | Environment variable | `config.toml` key | Default |
| ---- | -------------------- | ----------------- | | ---- | -------------------- | ----------------- | ------- |
| `--username` | `SOULSEEK_USERNAME` | `username` | | `--username` | `SOULSEEK_USERNAME` | `username` | — (required) |
| `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | | `--password` | `SOULSEEK_PASSWORD` | — (never stored in the file) | — (required) |
| `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | | `--password-cmd` | `SOULSEEK_PASSWORD_CMD` | `password_cmd` | — |
| `--server` | `SOULSEEK_SERVER` | `server` | | `--server` | `SOULSEEK_SERVER` | `server` | `server.slsknet.org:2416` |
| `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | | `--download-dir` | `SOULSEEK_DOWNLOAD_DIR` | `download_dir` | `<Downloads>/Soulseek` |
| `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | | `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir |
| `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` |
| `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on |
| `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `5` |
| `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` |
| `--log-file` | `SOULSEEK_LOG_FILE` | — | | `--log-file` | `SOULSEEK_LOG_FILE` | — | stderr |
Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`. Boolean environment variables accept the usual `1`/`0`/`true`/`false`/`yes`/`no`.
`--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different config file and `--config <FILE>` (or `SOULSEEK_CONFIG`) reads a different config file and
`--no-config` ignores it entirely, which is what an isolated or containerised `--no-config` ignores it entirely, which is what an isolated or containerised
run wants. The config file lives at `~/.config/soulseek-rs/config.toml` on run wants. The config file lives at `~/.config/soulseek-rs/config.toml` on
macOS and Linux; `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the macOS and Linux; `SOULSEEK_CONFIG_DIR` and `SOULSEEK_STATE_DIR` relocate the
config and state directories wholesale. config and state directories wholesale. Every variable above is also read from
a `.env` file in the working directory, which is often the tidiest way to hand
a container its credentials.
`config get` and `config set` cover the nine settings the file can hold —
`username`, `server`, `listener_port`, `disable_listener`, `download_dir`,
`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd` —
and name the lot back at you when you ask for something else. Setting a key to
an empty string clears it, and `shared_dirs` takes a comma-separated list.
Every wait expressed in seconds (`--search-timeout`, each `--timeout`, each
`--duration`) is bounded to one day, so a mistyped flag is rejected rather than
hanging until the heat death of the universe.
#### Credentials for unattended runs #### Credentials for unattended runs
+50 -5
View File
@@ -28,7 +28,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create and connect to Soulseek server // Create and connect to Soulseek server
let mut client = Client::new("username", "password"); let mut client = Client::new("username", "password");
client.connect(); client.connect()?;
client.login()?; client.login()?;
// Search for files // Search for files
@@ -37,13 +37,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Download first available file // Download first available file
if let Some(result) = results.iter().find(|r| !r.files.is_empty()) { if let Some(result) = results.iter().find(|r| !r.files.is_empty()) {
let file = &result.files[0]; let file = &result.files[0];
client.download( let (_download, status) = client.download(
file.name.clone(), file.name.clone(),
file.username.clone(), file.username.clone(),
file.size, file.size,
"~/Downloads".to_string(), "~/Downloads".to_string(),
)?; )?;
println!("Downloaded: {}", file.name);
// `download` returns as soon as the request is queued: the transfer is
// finished when the channel says so, not when this call returns.
for update in status {
println!("{}: {update:?}", file.name);
}
} }
Ok(()) Ok(())
@@ -58,16 +63,56 @@ use soulseek_rs::{Client, ClientSettings, PeerAddress};
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client with custom settings // Create client with custom settings
let settings = ClientSettings { let settings = ClientSettings {
server_address: PeerAddress::new("server.slsknet.org".to_string(), 2242), server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416),
enable_listen: true, enable_listen: true,
listen_port: 3000, listen_port: 3000,
..ClientSettings::new("username", "password") ..ClientSettings::new("username", "password")
}; };
let mut client = Client::with_settings(settings); let mut client = Client::with_settings(settings);
client.connect(); client.connect()?;
client.login()?; client.login()?;
Ok(()) Ok(())
} }
``` ```
### Asking about a peer
The server-backed lookups are request/poll pairs: you ask, then read the
answer once it lands. `request_user_info` clears any previous answer first, so
the loop below observes this request rather than the last one.
```rust
use soulseek_rs::Client;
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
client.request_user_info("someuser")?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Some(info) = client.user_info("someuser") {
// Presence and stats are separate replies, so each is an Option:
// a reply that never arrived is None, not "offline" or "shares nothing".
println!("{:?} {:?}", info.presence, info.stats);
break;
}
sleep(Duration::from_millis(100));
}
// Room membership is kept current from join and leave events.
println!("{:?}", client.room_members("lobby"));
Ok(())
}
```
`ServerMessage` and `ClientOperation` are `#[non_exhaustive]`: every protocol
message the client learns to handle adds a variant, so match them with a
wildcard arm.
+52 -12
View File
@@ -1,26 +1,66 @@
# soulseek-rs # soulseek-rs
A command-line client for the Soulseek peer-to-peer network. A Soulseek client for the terminal: a full-screen TUI, and a one-shot command
surface built for scripts and agents.
## Installation ## Installation
`bash cargo install soulseek-rs ` ```bash
cargo install soulseek-rs
This will install the `soulseek-rs` binary to your system. ```
## Usage ## Usage
The client provides a simple interface to search and download files from the Run it with no subcommand for the interactive TUI:
Soulseek network.
```bash
soulseek-rs
```
Anything else is a one-shot command that runs headless. stdout carries records
only, stderr carries progress, and the exit code carries the verdict:
```bash
soulseek-rs search <QUERY> # print matching files
soulseek-rs get <QUERY> # search, pick the best, download
soulseek-rs download <USER> <PATH> # fetch one known file
soulseek-rs download --stdin # fetch files listed on stdin
soulseek-rs browse <USER> # list a user's shared files
soulseek-rs serve [--follow] # stay online sharing, stream uploads
soulseek-rs whoami # confirm credentials and connection
soulseek-rs user <NAME> # a peer's status and share counts
soulseek-rs room list|say|users|listen
soulseek-rs message send|read
soulseek-rs shares list|add|remove|status|reindex
soulseek-rs config path|list|get|set
soulseek-rs portmap # test automatic port mapping
```
Add `--json` for newline-delimited JSON instead of tab-separated text, so
commands compose:
```bash
soulseek-rs search "rjd2" --json \
| jq -c 'select(.size > 5000000 and .free_slot)' \
| soulseek-rs download --stdin
```
Exit codes: `0` success, `2` usage or config, `3` connect or login, `4` nothing
found, `5` timed out, `6` transfer failed, `1` unexpected error.
The [full README](https://github.com/michel/soulseek-rs#readme) documents every
command, record shape, flag, environment variable and `config.toml` key.
## Building from Source ## Building from Source
````bash git clone https://github.com/yourusername/soulseek-rs cd soulseek-rs ```bash
cargo build --release ``` git clone https://github.com/michel/soulseek-rs.git
cd soulseek-rs
cargo build --release
```
## Library ## Library
If you want to build your own Soulseek client, check out the To build your own Soulseek client, use the
[soulseek-rs-lib](https://crates.io/crates/soulseek-rs-lib) library crate. [soulseek-rs-lib](https://crates.io/crates/soulseek-rs-lib) crate: the protocol
implementation this client is built on.
````