feat: install tab completion for bash, zsh, and fish (#16)

`completions install` generates the script from the same clap definition
the binary parses with, so it cannot drift from `--help`, and writes it
where each shell already looks. fish scans its own completions directory
unprompted; bash and zsh get one marked `source` line appended to their rc
file, because nothing puts a per-user directory on their search path by
default. `completions uninstall` takes back exactly those lines and leaves
the rest of the file alone.
This commit is contained in:
Michel de Graaf
2026-07-26 12:18:53 +02:00
committed by GitHub
parent 72f2e31a2c
commit 534b09a20a
13 changed files with 583 additions and 10 deletions
Generated
+10
View File
@@ -497,6 +497,15 @@ dependencies = [
"terminal_size", "terminal_size",
] ]
[[package]]
name = "clap_complete"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.1" version = "4.6.1"
@@ -2478,6 +2487,7 @@ version = "8.0.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
"clap_complete",
"color-eyre", "color-eyre",
"directories", "directories",
"dotenv", "dotenv",
+30 -5
View File
@@ -149,17 +149,18 @@ 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 soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
``` ```
`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.
Eleven of these need no credentials, because they never touch the network: Thirteen of these need no credentials, because they never touch the network:
`config path|list|get|set`, `shares list|add|remove`, `portmap` and `config path|list|get|set`, `shares list|add|remove`, `portmap`,
`skills install|uninstall|list`. `shares status` and `shares reindex` are not `skills install|uninstall|list` and `completions install|uninstall`.
among them: they report what the network will see, which means logging in and `shares status` and `shares reindex` are not among them: they report what the
scanning the folders. network will see, which means logging in and scanning the folders.
Every command emits records except the four that perform an action. Every command emits records except the four that perform an action.
`room say`, `message send`, `shares add` and `shares remove` say nothing on `room say`, `message send`, `shares add` and `shares remove` say nothing on
@@ -278,6 +279,30 @@ soulseek-rs skills install --dir ~/.config/some-agent/skills
soulseek-rs skills install --dir .claude/skills # commit it with the repo soulseek-rs skills install --dir .claude/skills # commit it with the repo
``` ```
### Shell completions
Tab completion is generated from the same `clap` definition the binary parses
with, so it never drifts from `--help`:
```bash
soulseek-rs completions install
```
That covers whichever of bash, zsh and fish this machine uses, writing each
script where that shell already looks — `~/.config/fish/completions` is scanned
unprompted, while bash and zsh also get one marked `source` line appended to
`~/.bashrc` or `~/.zshrc`, because nothing else puts a per-user directory on
their search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
```
installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs
installed fish /home/you/.config/fish/completions/soulseek-rs.fish
```
`completions uninstall` removes the scripts and takes back exactly the lines it
added, leaving the rest of your rc file alone. Pass `--shell bash` (repeatable)
to act on one shell rather than the ones detected.
### 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
+1
View File
@@ -28,6 +28,7 @@ workspace = true
soulseek-rs-lib = { version = "8.0.0", path = "../soulseek-rs-lib" } soulseek-rs-lib = { version = "8.0.0", path = "../soulseek-rs-lib" }
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
clap_complete = "4.6.7"
ratatui = "0.30.2" ratatui = "0.30.2"
color-eyre = "0.6.5" color-eyre = "0.6.5"
dotenv = "0.15.0" dotenv = "0.15.0"
+1
View File
@@ -37,6 +37,7 @@ soulseek-rs message send|read
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 completions install # tab completion for bash/zsh/fish
``` ```
Add `--json` for newline-delimited JSON instead of tab-separated text, so Add `--json` for newline-delimited JSON instead of tab-separated text, so
+4
View File
@@ -97,6 +97,10 @@ answered, which is a negative answer rather than an error.
this skill file into the local agent directories, or remove it: `agent`, this skill file into the local agent directories, or remove it: `agent`,
`path`, `action`. `path`, `action`.
**`completions install`** and **`completions uninstall`** — write the tab
completion script where bash, zsh, or fish looks for it, or take it back
again: `shell`, `path`, `action`. For a human at a prompt, not for you.
## Idioms ## Idioms
```bash ```bash
+30
View File
@@ -27,6 +27,7 @@ Examples:
soulseek-rs serve --follow --json share files, stream upload events soulseek-rs serve --follow --json share files, stream upload events
soulseek-rs user someuser is this peer online and worth it soulseek-rs user someuser is this peer online and worth it
soulseek-rs skills install teach your coding agent this CLI soulseek-rs skills install teach your coding agent this CLI
soulseek-rs completions install tab completion for your shell
Exit codes: Exit codes:
0 success 2 usage/config 3 connect or login 4 no results 0 success 2 usage/config 3 connect or login 4 no results
@@ -198,6 +199,35 @@ pub enum Commands {
/// Teach a local coding agent this command surface /// Teach a local coding agent this command surface
#[command(subcommand)] #[command(subcommand)]
Skills(SkillsCommand), Skills(SkillsCommand),
/// Tab completion for bash, zsh, and fish
#[command(subcommand)]
Completions(CompletionsCommand),
}
#[derive(Subcommand, Debug)]
pub enum CompletionsCommand {
/// Write the completion script where the shell looks for it
Install(CompletionsArgs),
/// Remove the completion script again
Uninstall(CompletionsArgs),
}
#[derive(Args, Debug)]
pub struct CompletionsArgs {
/// Shell to act on instead of the ones found on this machine; repeat for
/// several
#[arg(long, value_enum, action = ArgAction::Append)]
pub shell: Vec<Shell>,
}
/// The shells with a conventional per-user completions location.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
+405
View File
@@ -0,0 +1,405 @@
//! Tab completion for the shell: `completions`.
//!
//! The script is generated from the same `clap` definition the binary parses
//! with, so it never drifts. Installing means putting it where the shell
//! already looks — which is enough on its own only for fish; bash and zsh each
//! get one `source` line appended to their rc file, marked so `uninstall` can
//! take back exactly what was added and nothing else.
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
use crate::output::{CliError, CliResult, CompletionRecord, Out};
use clap::CommandFactory;
use directories::UserDirs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// The command being completed, and the file name the scripts are written as.
const BIN: &str = "soulseek-rs";
/// What every line this command writes into an rc file ends with, and the only
/// thing `uninstall` will delete from one.
const MARKER: &str = "# soulseek-rs completions";
/// The directories the install locations hang off, resolved once so the rest
/// of the module is a pure function of them.
struct Dirs {
home: PathBuf,
data: PathBuf,
config: PathBuf,
}
/// One shell's completion script, and the rc file that has to load it.
struct Target {
shell: Shell,
script: PathBuf,
/// `None` for fish, whose completions directory is scanned automatically.
rc: Option<PathBuf>,
}
impl Target {
fn record(&self, action: &str) -> CompletionRecord {
CompletionRecord {
shell: self.shell.name().to_string(),
path: self.script.display().to_string(),
action: action.to_string(),
}
}
fn source_line(&self) -> String {
format!("source \"{}\" {MARKER}", self.script.display())
}
}
impl Shell {
const fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}
const fn generator(self) -> clap_complete::Shell {
match self {
Self::Bash => clap_complete::Shell::Bash,
Self::Zsh => clap_complete::Shell::Zsh,
Self::Fish => clap_complete::Shell::Fish,
}
}
}
pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult {
match command {
CompletionsCommand::Install(args) => install(out, &targets(args)?),
CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
}
}
fn install(out: &Out, targets: &[Target]) -> CliResult {
for target in targets {
let mut changed = write_script(&target.script, &script(target.shell))?;
if let Some(rc) = &target.rc {
changed |= add_source_line(rc, &target.source_line())?;
}
out.emit(&target.record(if changed {
"installed"
} else {
"unchanged"
}));
}
out.status("open a new shell to pick it up");
Ok(())
}
fn uninstall(out: &Out, targets: &[Target]) -> CliResult {
for target in targets {
let mut changed = remove_script(&target.script)?;
if let Some(rc) = &target.rc {
changed |= remove_source_lines(rc)?;
}
out.emit(&target.record(if changed { "removed" } else { "absent" }));
}
Ok(())
}
/// The completion script as this binary's own argument parser defines it.
fn script(shell: Shell) -> Vec<u8> {
let mut buffer = Vec::new();
clap_complete::generate(
shell.generator(),
&mut Cli::command(),
BIN,
&mut buffer,
);
buffer
}
/// Which shells to act on. Naming shells aims the command at exactly those;
/// detection is what answers the question when nothing does.
fn targets(args: &CompletionsArgs) -> CliResult<Vec<Target>> {
let dirs = Dirs::detect()?;
let shells = if args.shell.is_empty() {
let found = detected(&dirs, std::env::var("SHELL").ok().as_deref());
if found.is_empty() {
return Err(CliError::no_results(
"no bash, zsh, or fish setup found in your home directory; \
name one with --shell",
));
}
found
} else {
args.shell.clone()
};
Ok(shells
.into_iter()
.map(|shell| target(shell, &dirs))
.collect())
}
/// Where each shell looks. bash and zsh both read a per-user directory that
/// nothing puts on the search path by default, hence the rc line; fish scans
/// its own completions directory unprompted.
fn target(shell: Shell, dirs: &Dirs) -> Target {
let (script, rc) = match shell {
Shell::Bash => (
dirs.data.join("bash-completion/completions").join(BIN),
Some(bash_rc(&dirs.home)),
),
Shell::Zsh => (
dirs.data.join("zsh/site-functions").join(format!("_{BIN}")),
Some(dirs.home.join(".zshrc")),
),
Shell::Fish => (
dirs.config
.join("fish/completions")
.join(format!("{BIN}.fish")),
None,
),
};
Target { shell, script, rc }
}
/// A shell counts as present when it has left configuration behind, or when it
/// is the one the user is logged into — a fresh macOS zsh account has no
/// `.zshrc` at all until something writes one.
fn detected(dirs: &Dirs, login_shell: Option<&str>) -> Vec<Shell> {
let running =
|name: &str| login_shell.is_some_and(|path| path.ends_with(name));
[
(
Shell::Bash,
dirs.home.join(".bashrc").exists()
|| dirs.home.join(".bash_profile").exists()
|| running("bash"),
),
(
Shell::Zsh,
dirs.home.join(".zshrc").exists() || running("zsh"),
),
(
Shell::Fish,
dirs.config.join("fish").is_dir() || running("fish"),
),
]
.into_iter()
.filter_map(|(shell, present)| present.then_some(shell))
.collect()
}
/// Interactive bash reads `.bashrc`, except on machines where only the login
/// file exists — which is the common macOS shape.
fn bash_rc(home: &Path) -> PathBuf {
let profile = home.join(".bash_profile");
let rc = home.join(".bashrc");
if !rc.exists() && profile.exists() {
profile
} else {
rc
}
}
impl Dirs {
fn detect() -> CliResult<Self> {
let home = UserDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.ok_or_else(|| {
CliError::usage("cannot find your home directory")
})?;
Ok(Self {
data: xdg("XDG_DATA_HOME", &home, ".local/share"),
config: xdg("XDG_CONFIG_HOME", &home, ".config"),
home,
})
}
}
/// Shells follow the XDG layout on every platform, unlike this client's own
/// config, which follows each platform's convention (see `persist::paths`).
fn xdg(variable: &str, home: &Path, fallback: &str) -> PathBuf {
std::env::var_os(variable)
.filter(|dir| !dir.is_empty())
.map_or_else(|| home.join(fallback), PathBuf::from)
}
fn write_script(path: &Path, script: &[u8]) -> CliResult<bool> {
if std::fs::read(path).is_ok_and(|found| found == script) {
return Ok(false);
}
create_parent(path)?;
std::fs::write(path, script).map_err(|e| unwritable(path, &e))?;
Ok(true)
}
fn remove_script(path: &Path) -> CliResult<bool> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(unwritable(path, &e)),
}
}
fn add_source_line(rc: &Path, line: &str) -> CliResult<bool> {
let existing = std::fs::read_to_string(rc).unwrap_or_default();
if existing.lines().any(|found| found == line) {
return Ok(false);
}
create_parent(rc)?;
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(rc)
.map_err(|e| unwritable(rc, &e))?;
writeln!(file, "{separator}{line}").map_err(|e| unwritable(rc, &e))?;
Ok(true)
}
/// Take back only the marked lines. Somebody else's rc file is a bad thing to
/// rewrite from memory, so every other line is preserved verbatim.
fn remove_source_lines(rc: &Path) -> CliResult<bool> {
let Ok(existing) = std::fs::read_to_string(rc) else {
return Ok(false);
};
let kept: Vec<&str> = existing
.lines()
.filter(|line| !line.ends_with(MARKER))
.collect();
if kept.len() == existing.lines().count() {
return Ok(false);
}
let mut rewritten = kept.join("\n");
if !rewritten.is_empty() {
rewritten.push('\n');
}
std::fs::write(rc, rewritten).map_err(|e| unwritable(rc, &e))?;
Ok(true)
}
fn create_parent(path: &Path) -> CliResult {
match path.parent() {
Some(parent) => {
std::fs::create_dir_all(parent).map_err(|e| unwritable(parent, &e))
}
None => Ok(()),
}
}
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
CliError::usage(format!("cannot write {}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(home: &Path) -> Dirs {
Dirs {
home: home.to_path_buf(),
data: home.join(".local/share"),
config: home.join(".config"),
}
}
/// The rc line only works because sourcing the script registers the
/// completion rather than just defining a function. zsh is the one that
/// could plausibly go the other way: an autoload-style `#compdef` file does
/// nothing on its own, so the install rests on the footer clap emits.
#[test]
fn a_sourced_script_registers_the_command() {
let zsh = String::from_utf8(script(Shell::Zsh)).expect("utf-8");
assert!(zsh.contains("compdef _soulseek-rs soulseek-rs"));
let bash = String::from_utf8(script(Shell::Bash)).expect("utf-8");
assert!(bash.contains("complete -F _soulseek-rs"));
}
#[test]
fn each_shell_lands_where_it_already_looks() {
let home = PathBuf::from("/home/me");
let dirs = dirs(&home);
for (shell, script, rc) in [
(
Shell::Bash,
".local/share/bash-completion/completions/soulseek-rs",
Some(".bashrc"),
),
(
Shell::Zsh,
".local/share/zsh/site-functions/_soulseek-rs",
Some(".zshrc"),
),
// fish scans its own completions directory, so it needs no rc line.
(
Shell::Fish,
".config/fish/completions/soulseek-rs.fish",
None,
),
] {
let target = target(shell, &dirs);
assert_eq!(target.script, home.join(script));
assert_eq!(target.rc, rc.map(|file| home.join(file)));
}
}
#[test]
fn only_shells_this_machine_uses_become_targets() {
let home = tempfile::tempdir().expect("tempdir");
let dirs = dirs(home.path());
assert!(detected(&dirs, None).is_empty());
assert_eq!(detected(&dirs, Some("/bin/zsh")), [Shell::Zsh]);
std::fs::write(home.path().join(".bashrc"), "").expect("bashrc");
std::fs::create_dir_all(dirs.config.join("fish")).expect("fish");
assert_eq!(detected(&dirs, None), [Shell::Bash, Shell::Fish]);
}
#[test]
fn bash_falls_back_to_the_login_file_when_that_is_all_there_is() {
let home = tempfile::tempdir().expect("tempdir");
assert_eq!(bash_rc(home.path()), home.path().join(".bashrc"));
std::fs::write(home.path().join(".bash_profile"), "").expect("write");
assert_eq!(bash_rc(home.path()), home.path().join(".bash_profile"));
std::fs::write(home.path().join(".bashrc"), "").expect("write");
assert_eq!(bash_rc(home.path()), home.path().join(".bashrc"));
}
#[test]
fn installing_twice_adds_one_line_and_uninstalling_takes_only_that() {
let home = tempfile::tempdir().expect("tempdir");
let rc = home.path().join(".zshrc");
std::fs::write(&rc, "export EDITOR=vi").expect("rc");
let target = target(Shell::Zsh, &dirs(home.path()));
let line = target.source_line();
assert!(add_source_line(&rc, &line).expect("add"));
assert!(!add_source_line(&rc, &line).expect("add again"));
let after = std::fs::read_to_string(&rc).expect("read");
assert_eq!(after, format!("export EDITOR=vi\n{line}\n"));
assert!(remove_source_lines(&rc).expect("remove"));
assert!(!remove_source_lines(&rc).expect("remove again"));
assert_eq!(
std::fs::read_to_string(&rc).expect("read"),
"export EDITOR=vi\n"
);
}
#[test]
fn a_script_is_written_once_and_removed_once() {
let home = tempfile::tempdir().expect("tempdir");
let path = target(Shell::Fish, &dirs(home.path())).script;
let fish = script(Shell::Fish);
assert!(write_script(&path, &fish).expect("write"));
assert!(!write_script(&path, &fish).expect("write again"));
assert_eq!(std::fs::read(&path).expect("read"), fish);
assert!(remove_script(&path).expect("remove"));
assert!(!remove_script(&path).expect("remove again"));
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
//! first; the ones that only read the config file or the local machine are //! first; the ones that only read the config file or the local machine are
//! dispatched by `main::run` before any credentials are demanded. //! dispatched by `main::run` before any credentials are demanded.
pub mod completions;
pub mod peer; pub mod peer;
pub mod settings; pub mod settings;
pub mod skills; pub mod skills;
@@ -196,7 +197,10 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
// These need no account, so main.rs answers them before asking for // These need no account, so main.rs answers them before asking for
// credentials; the arms exist only to keep the match exhaustive. // credentials; the arms exist only to keep the match exhaustive.
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port), Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) => { Commands::Config(_)
| Commands::Shares(_)
| Commands::Skills(_)
| Commands::Completions(_) => {
Err(CliError::usage("handled without a session; see main::run")) Err(CliError::usage("handled without a session; see main::run"))
} }
} }
+3
View File
@@ -64,6 +64,9 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
Commands::Skills(ref command) => { Commands::Skills(ref command) => {
return commands::skills::run(out, command); return commands::skills::run(out, command);
} }
Commands::Completions(ref command) => {
return commands::completions::run(out, command);
}
Commands::Config(ConfigCommand::Path) => { Commands::Config(ConfigCommand::Path) => {
return commands::settings::config_path(out, &store); return commands::settings::config_path(out, &store);
} }
+22
View File
@@ -445,6 +445,28 @@ impl Record for SkillRecord {
} }
} }
/// What happened to one shell's completion script.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CompletionRecord {
/// `bash`, `zsh`, or `fish`.
pub shell: String,
pub path: String,
/// `installed` (written), `unchanged` (already identical), `removed`, or
/// `absent`.
pub action: String,
}
impl Record for CompletionRecord {
fn text(&self) -> String {
format!(
"{}\t{}\t{}",
sanitize(&self.action),
sanitize(&self.shell),
sanitize(&self.path)
)
}
}
/// The result of probing automatic port mapping. /// The result of probing automatic port mapping.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortmapRecord { pub struct PortmapRecord {
+53 -1
View File
@@ -93,8 +93,15 @@ fn help_lists_every_scriptable_command() {
assert_eq!(code(&output), EXIT_OK); assert_eq!(code(&output), EXIT_OK);
let help = stdout(&output); let help = stdout(&output);
for command in [ for command in [
"search", "download", "get", "browse", "room", "message", "portmap", "search",
"download",
"get",
"browse",
"room",
"message",
"portmap",
"skills", "skills",
"completions",
] { ] {
assert!(help.contains(command), "--help should mention {command}"); assert!(help.contains(command), "--help should mention {command}");
} }
@@ -333,6 +340,51 @@ fn listing_skills_reports_without_writing_anything() {
); );
} }
/// fish is the shell the whole round trip can be driven for on every platform:
/// its completions directory hangs off `XDG_CONFIG_HOME`, so nothing outside
/// the scratch directory is read or written. bash and zsh also append a line
/// to a real rc file under `$HOME`, which is not a thing to point a test at on
/// Windows — the unit tests cover that half.
#[test]
fn installing_completions_is_repeatable_and_reversible() {
let target = Scratch::new("completions");
let installed = target
.path()
.join("fish")
.join("completions")
.join("soulseek-rs.fish");
let completions = |verb: &str| {
command(&["--json", "completions", verb, "--shell", "fish"])
.env("XDG_CONFIG_HOME", target.path())
.output()
.expect("the binary should run")
};
let first = completions("install");
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
assert_eq!(action(&first), "installed");
let script = std::fs::read_to_string(&installed).expect("a fish script");
assert!(script.contains("complete -c soulseek-rs"));
assert!(
script.contains("-l download-dir"),
"flags are completed too"
);
assert_eq!(
action(&completions("install")),
"unchanged",
"reinstalling is how you update"
);
let removed = completions("uninstall");
assert_eq!(action(&removed), "removed");
assert!(!installed.exists());
let again = completions("uninstall");
assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure");
assert_eq!(action(&again), "absent");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Server required (soulfind) // Server required (soulfind)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+7 -2
View File
@@ -181,14 +181,19 @@ export const Docs = () => {
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '} <Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '} <Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '} <Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
<Code>portmap</Code>, <Code>skills</Code>. Records are tab-separated fields, <Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records
one per line, or NDJSON with <Code>--json</Code>. are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
</p> </p>
<p> <p>
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '} No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
nothing, 3 could not reach the server, 6 the transfer died. nothing, 3 could not reach the server, 6 the transfer died.
</p> </p>
<p>
<Code>completions install</Code> gives bash, zsh and fish tab completion for
all of it, generated from the same definition the binary parses with.{' '}
<Code>completions uninstall</Code> takes it back out.
</p>
<p> <p>
<Code>portmap</Code> tests whether your router will let peers connect back, <Code>portmap</Code> tests whether your router will let peers connect back,
worth running once.{' '} worth running once.{' '}
+12 -1
View File
@@ -301,6 +301,12 @@ export const Install = () => {
a terminal and the TUI opens, in a script or a pipe it wants a subcommand a terminal and the TUI opens, in a script or a pipe it wants a subcommand
instead. instead.
</p> </p>
<p className="text-[13px] text-muted">
<Code>soulseek-rs completions install</Code> then adds tab completion to
whichever of bash, zsh and fish this machine uses &mdash; generated from the
same definition the binary parses with, so it never drifts from{' '}
<Code>--help</Code>. Open a new shell to pick it up.
</p>
<div className="flex flex-wrap gap-2 sm:gap-3"> <div className="flex flex-wrap gap-2 sm:gap-3">
<Button href={LINKS.releases}>Prebuilt binaries</Button> <Button href={LINKS.releases}>Prebuilt binaries</Button>
</div> </div>
@@ -519,13 +525,17 @@ export const Install = () => {
<Section band> <Section band>
<SectionHead eyebrow="uninstall" title="Removing it completely."> <SectionHead eyebrow="uninstall" title="Removing it completely.">
Three things exist on disk: the binary, the config, and the state. Nothing else, Three things exist on disk: the binary, the config, and the state &mdash; plus
the completion scripts and the agent skill, if you asked for those. Nothing else,
unless you point <Code>--log-file</Code> somewhere. No telemetry to opt out of. unless you point <Code>--log-file</Code> somewhere. No telemetry to opt out of.
</SectionHead> </SectionHead>
<Cols start> <Cols start>
<Terminal <Terminal
label="macOS · Linux" label="macOS · Linux"
lines={[ lines={[
{ t: 'cm', text: '# while the binary is still there' },
{ t: 'cmd', text: 'soulseek-rs completions uninstall' },
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' },
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' }, { t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
{ {
@@ -539,6 +549,7 @@ export const Install = () => {
<Terminal <Terminal
label="Windows · PowerShell" label="Windows · PowerShell"
lines={[ lines={[
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' },
{ {
t: 'cmd', t: 'cmd',