diff --git a/Cargo.lock b/Cargo.lock
index fd3e979..8ff68e4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -497,6 +497,15 @@ dependencies = [
"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]]
name = "clap_derive"
version = "4.6.1"
@@ -2478,6 +2487,7 @@ version = "8.0.0"
dependencies = [
"chrono",
"clap",
+ "clap_complete",
"color-eyre",
"directories",
"dotenv",
diff --git a/README.md b/README.md
index 03648b5..5460ede 100644
--- a/README.md
+++ b/README.md
@@ -149,17 +149,18 @@ soulseek-rs shares list|add|remove|status|reindex
soulseek-rs config path|list|get|set
soulseek-rs portmap # test automatic port mapping
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
first: the config commands need no account at all, and `whoami` answers
"are these credentials good and what am I offering" in one call.
-Eleven of these need no credentials, because they never touch the network:
-`config path|list|get|set`, `shares list|add|remove`, `portmap` and
-`skills install|uninstall|list`. `shares status` and `shares reindex` are not
-among them: they report what the network will see, which means logging in and
-scanning the folders.
+Thirteen of these need no credentials, because they never touch the network:
+`config path|list|get|set`, `shares list|add|remove`, `portmap`,
+`skills install|uninstall|list` and `completions install|uninstall`.
+`shares status` and `shares reindex` are not among them: they report what the
+network will see, which means logging in and scanning 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
@@ -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
```
+### 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` 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
`serve` is the mode that makes this client a source: it stays logged in, keeps
diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml
index 2a2e845..40cfc6c 100644
--- a/soulseek-rs/Cargo.toml
+++ b/soulseek-rs/Cargo.toml
@@ -28,6 +28,7 @@ workspace = true
soulseek-rs-lib = { version = "8.0.0", path = "../soulseek-rs-lib" }
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
+clap_complete = "4.6.7"
ratatui = "0.30.2"
color-eyre = "0.6.5"
dotenv = "0.15.0"
diff --git a/soulseek-rs/README.md b/soulseek-rs/README.md
index b95dacb..c81f892 100644
--- a/soulseek-rs/README.md
+++ b/soulseek-rs/README.md
@@ -37,6 +37,7 @@ 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
+soulseek-rs completions install # tab completion for bash/zsh/fish
```
Add `--json` for newline-delimited JSON instead of tab-separated text, so
diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md
index d70f6f7..6da4d96 100644
--- a/soulseek-rs/src/SKILL.md
+++ b/soulseek-rs/src/SKILL.md
@@ -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`,
`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
```bash
diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs
index f8c00d7..e8923e5 100644
--- a/soulseek-rs/src/cli.rs
+++ b/soulseek-rs/src/cli.rs
@@ -27,6 +27,7 @@ Examples:
soulseek-rs serve --follow --json share files, stream upload events
soulseek-rs user someuser is this peer online and worth it
soulseek-rs skills install teach your coding agent this CLI
+ soulseek-rs completions install tab completion for your shell
Exit codes:
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
#[command(subcommand)]
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,
+}
+
+/// 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)]
diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs
new file mode 100644
index 0000000..8dd2687
--- /dev/null
+++ b/soulseek-rs/src/commands/completions.rs
@@ -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,
+}
+
+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 {
+ 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> {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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"));
+ }
+}
diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs
index 7e98ff9..11a303b 100644
--- a/soulseek-rs/src/commands/mod.rs
+++ b/soulseek-rs/src/commands/mod.rs
@@ -5,6 +5,7 @@
//! first; the ones that only read the config file or the local machine are
//! dispatched by `main::run` before any credentials are demanded.
+pub mod completions;
pub mod peer;
pub mod settings;
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
// credentials; the arms exist only to keep the match exhaustive.
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"))
}
}
diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs
index 2aca61e..b7e44c6 100644
--- a/soulseek-rs/src/main.rs
+++ b/soulseek-rs/src/main.rs
@@ -64,6 +64,9 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
Commands::Skills(ref command) => {
return commands::skills::run(out, command);
}
+ Commands::Completions(ref command) => {
+ return commands::completions::run(out, command);
+ }
Commands::Config(ConfigCommand::Path) => {
return commands::settings::config_path(out, &store);
}
diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs
index 813985b..e97c115 100644
--- a/soulseek-rs/src/output.rs
+++ b/soulseek-rs/src/output.rs
@@ -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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortmapRecord {
diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs
index 937e3d3..7825acc 100644
--- a/soulseek-rs/tests/cli_e2e.rs
+++ b/soulseek-rs/tests/cli_e2e.rs
@@ -93,8 +93,15 @@ fn help_lists_every_scriptable_command() {
assert_eq!(code(&output), EXIT_OK);
let help = stdout(&output);
for command in [
- "search", "download", "get", "browse", "room", "message", "portmap",
+ "search",
+ "download",
+ "get",
+ "browse",
+ "room",
+ "message",
+ "portmap",
"skills",
+ "completions",
] {
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)
// ---------------------------------------------------------------------------
diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx
index 5641433..c361919 100644
--- a/website/src/pages/docs.tsx
+++ b/website/src/pages/docs.tsx
@@ -181,14 +181,19 @@ export const Docs = () => {
download, browse, room,{' '}
message, serve, user,{' '}
whoami, shares, config,{' '}
- portmap, skills. Records are tab-separated fields,
- one per line, or NDJSON with --json.
+ portmap, skills, completions. Records
+ are tab-separated fields, one per line, or NDJSON with --json.
No TTY, no daemon. search --json pipes through jq{' '}
into download --stdin. Every ending has its own exit code: 4 found
nothing, 3 could not reach the server, 6 the transfer died.
+
+ completions install gives bash, zsh and fish tab completion for
+ all of it, generated from the same definition the binary parses with.{' '}
+ completions uninstall takes it back out.
+
portmap tests whether your router will let peers connect back,
worth running once.{' '}
diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx
index ba0c4b2..93a28b1 100644
--- a/website/src/pages/install.tsx
+++ b/website/src/pages/install.tsx
@@ -301,6 +301,12 @@ export const Install = () => {
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
instead.
+
+ soulseek-rs completions install then adds tab completion to
+ whichever of bash, zsh and fish this machine uses — generated from the
+ same definition the binary parses with, so it never drifts from{' '}
+ --help. Open a new shell to pick it up.
+
@@ -519,13 +525,17 @@ export const Install = () => {
- 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 — plus
+ the completion scripts and the agent skill, if you asked for those. Nothing else,
unless you point --log-file somewhere. No telemetry to opt out of.
{