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",
]
[[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",
+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 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<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
`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" }
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"
+1
View File
@@ -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
+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`,
`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
+30
View File
@@ -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<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)]
+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
//! 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"))
}
}
+3
View File
@@ -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);
}
+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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortmapRecord {
+53 -1
View File
@@ -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)
// ---------------------------------------------------------------------------
+7 -2
View File
@@ -181,14 +181,19 @@ export const Docs = () => {
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
<Code>portmap</Code>, <Code>skills</Code>. Records are tab-separated fields,
one per line, or NDJSON with <Code>--json</Code>.
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records
are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
</p>
<p>
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
nothing, 3 could not reach the server, 6 the transfer died.
</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>
<Code>portmap</Code> tests whether your router will let peers connect back,
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
instead.
</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">
<Button href={LINKS.releases}>Prebuilt binaries</Button>
</div>
@@ -519,13 +525,17 @@ export const Install = () => {
<Section band>
<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.
</SectionHead>
<Cols start>
<Terminal
label="macOS · Linux"
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: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
{
@@ -539,6 +549,7 @@ export const Install = () => {
<Terminal
label="Windows · PowerShell"
lines={[
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
{
t: 'cmd',