feat(cli): expose sharing, peer lookup and settings as one-shot commands

An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent be6581ee6c
commit 03e8924a6b
21 changed files with 2187 additions and 19 deletions
+36 -1
View File
@@ -5,7 +5,7 @@ use super::{Ctx, Session, confirm_flush};
use crate::cli::BrowseArgs;
use crate::output::{
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
RoomEventRecord, RoomRecord,
RoomEventRecord, RoomMemberRecord, RoomRecord,
};
use soulseek_rs::types::RoomEvent;
use soulseek_rs::{Client, SharedDirectory};
@@ -132,6 +132,41 @@ pub fn room_say(ctx: &Ctx, room: &str, message: &str) -> CliResult {
)))
}
/// List a room's membership. Joining is what makes the server send it, so we
/// join, wait for the roster, and leave again.
pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {
CliError::connection(format!("cannot join {room}: {e}"))
})?;
ctx.out
.status(&format!("waiting for {room}'s member list…"));
let deadline = Instant::now() + timeout;
let mut members = Vec::new();
while Instant::now() < deadline && members.is_empty() {
members = session.client.room_members(room);
if members.is_empty() {
std::thread::sleep(POLL);
}
}
let _ = session.client.leave_room(room);
if members.is_empty() {
return Err(CliError::timeout(format!(
"{room} sent no member list within {}s",
timeout.as_secs()
)));
}
for user in members {
ctx.out.emit(&RoomMemberRecord {
room: room.to_string(),
user,
});
}
Ok(())
}
pub fn room_listen(ctx: &Ctx, room: &str, span: Option<Duration>) -> CliResult {
let session = Session::open(ctx)?;
session.client.join_room(room).map_err(|e| {