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. {