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
+55
View File
@@ -2,6 +2,7 @@ use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error,
};
use crate::types::UserInfo;
impl Client {
/// Send a private message to another user via the server.
@@ -101,6 +102,60 @@ impl Client {
}
}
/// Ask the server what it knows about `username`: online status and share
/// statistics.
///
/// Any previous answer for `username` is discarded first, so
/// [`Client::user_info`] returns `None` until the replies to *this*
/// request arrive — polling it cannot mistake a stale snapshot for a
/// fresh one.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_user_info(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.context.write_safe()?.invalidate_user_info(username);
self.send_server_message(MessageFactory::build_get_user_status(
username,
))?;
self.send_server_message(MessageFactory::build_get_user_stats(username))
}
/// What the server has told us about `username` since the last
/// [`Client::request_user_info`], or `None` before any reply arrives.
///
/// Presence and statistics are separate replies, so a snapshot can carry
/// one and not the other; [`UserInfo::is_complete`] reports when both
/// have landed.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.user_info(username),
Err(e) => {
error!("[client] user_info: {}", e);
None
}
}
}
/// Who is currently in `room`, sorted by name.
///
/// The roster is built from the membership the server sends when
/// [`Client::join_room`] succeeds and kept current by the join and leave
/// events after it, so it is populated shortly after joining rather than
/// immediately. Returns an empty list for a room this client has not
/// joined.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_members(room),
Err(e) => {
error!("[client] room_members: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> {