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
+48 -6
View File
@@ -8,7 +8,7 @@ mod port_mapping;
mod ui;
use clap::Parser;
use cli::{Cli, Commands, parse_server_address};
use cli::{Cli, Commands, ConfigCommand, SharesCommand, parse_server_address};
use commands::Ctx;
use output::{CliError, CliResult, Exit, Out};
use soulseek_rs::{ClientSettings, PeerAddress};
@@ -50,14 +50,56 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
return run_default_tui(&cli, &resolved, config_path, &file_config);
};
// A local network probe needs no account, so it runs before credentials
// are demanded.
if matches!(command, Commands::Portmap) {
return commands::portmap(out, resolved.listener_port);
// Anything that only reads the network stack or the config file runs
// before credentials are demanded: asking a script for a login it does not
// need would be a poor trade for a `config get`.
let store = commands::settings::Store {
path: config_path,
config: file_config,
};
match command {
Commands::Portmap => {
return commands::portmap(out, resolved.listener_port);
}
Commands::Config(ConfigCommand::Path) => {
return commands::settings::config_path(out, &store);
}
Commands::Config(ConfigCommand::List) => {
commands::settings::config_list(out, &store);
return Ok(());
}
Commands::Config(ConfigCommand::Get { ref key }) => {
return commands::settings::config_get(out, &store, key);
}
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
return commands::settings::config_set(out, &store, key, value);
}
Commands::Shares(SharesCommand::List) => {
return commands::settings::shares_list(
out,
&store,
&resolved.shared_dirs,
);
}
Commands::Shares(SharesCommand::Add { ref directory }) => {
return commands::settings::shares_add(out, &store, directory);
}
Commands::Shares(SharesCommand::Remove { ref directory }) => {
return commands::settings::shares_remove(out, &store, directory);
}
_ => {}
}
let ctx = context(&cli, &resolved, out)?;
commands::run(&ctx, command)
match command {
Commands::Shares(SharesCommand::Status) => {
commands::settings::shares_status(&ctx)
}
Commands::Shares(SharesCommand::Reindex) => {
commands::settings::shares_reindex(&ctx)
}
other => commands::run(&ctx, other),
}
}
/// Which config file to read: an explicit `--config`, the platform default,