diff --git a/AGENTS.md b/AGENTS.md index 7ec9f51d..e5a788eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ src/ remove.rs — `numan remove [--force] `: remove from lockfile + delete payload (Phase 5); `--force` bypasses module activation only (active plugins always gated until deactivate, Issue #22) gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5) nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) - completions.rs — `numan completions `: bash/fish/zsh/powershell/nushell scripts (Phase 7.3) + completions.rs — `numan completions `: install by default (mkdir+write); `--print` for stdout (Phase 7.3) setup.rs — `numan setup nu [VERSION]|remove|path|use ` + `setup loader`: Nushell bootstrap + nushell-loader install try_cmd.rs — `numan try [--yes] [--no-activate]`: curated starter install + activate for current Nu use_cmd.rs — `numan use |latest|list`: activates a previously installed managed Nu version (no auto-download); writes the active-version marker after a PreMutation snapshot under the root mutation lock diff --git a/CHANGELOG.md b/CHANGELOG.md index ded50fc3..c18aaf47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release assets** for `aarch64-unknown-linux-gnu` and `aarch64-pc-windows-msvc` (native ARM runners); `numan update --self` maps those triples - **Homebrew** Linux ARM archive support; **winget** verifies and submits both Windows x64 and ARM64 zips +### Changed + +- **`numan completions `** installs by default (creates the target directory if needed). Use `--print` to emit the script on stdout for piping or custom redirects. + ### Fixed - **`numan setup nu`**: official Nushell 0.114.x release archives exceed the old diff --git a/README.md b/README.md index 1cb12fb5..8e705dcc 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ These workflows are covered by unit tests, hermetic integration tests, and real- - **Lifecycle management**: Update, remove, and garbage collection operations recover safely through lifecycle journals. - **nupm interoperability**: Use `numan nupm status`, `inspect`, `import`, and `diff` to inspect, migrate, and detect drift in existing [nupm](https://github.com/nushell/nupm) installations. - **Health checks**: `numan doctor` diagnoses installation health and applies safe repairs by default. Use `--scan` for report-only mode. -- **Shell completions**: Generate completions for Bash, Fish, Zsh, PowerShell, and Nushell with `numan completions`. +- **Shell completions**: Install completions for Bash, Fish, Zsh, PowerShell, and Nushell with `numan completions` (use `--print` to emit the script). --- @@ -67,8 +67,8 @@ These workflows are covered by unit tests, hermetic integration tests, and real- Install-only packages remain inert: numan downloads, verifies, locks, lists, removes, and garbage-collects their payloads, but does not execute them or modify Nu configuration for them. This is separate from numan's own shell -completion generator: `numan completions ` is supported for bash, fish, -zsh, PowerShell, and Nushell (`nu`). +completion installer: `numan completions ` is supported for bash, fish, +zsh, PowerShell, and Nushell (`nu`); use `--print` to emit the script instead. --- @@ -151,33 +151,21 @@ Requires [Rust](https://rustup.rs/) (stable). The installed binary is named `num ### Shell completions -`numan completions ` prints the script on stdout and a copy-ready install command on stderr. +`numan completions ` installs to the canonical path and creates parent directories if needed. Use `--print` to emit the script on stdout (pipe-safe; redirect hints go to stderr). ```bash -# Bash -numan completions bash > ~/.local/share/bash-completion/completions/numan - -# Zsh -numan completions zsh > ~/.zfunc/_numan - -# Fish -numan completions fish > ~/.config/fish/completions/numan.fish - -# PowerShell (append to $PROFILE; do not use Out-File — that overwrites the profile) -numan completions powershell | Add-Content -Encoding utf8 $PROFILE - -# Nushell (vendor autoload; `nu` is accepted as an alias for `nushell`) -mkdir ($nu.data-dir | path join vendor/autoload) -numan completions nushell | save -f ($nu.data-dir | path join vendor/autoload/numan-completions.nu) +numan completions bash +numan completions zsh +numan completions fish +numan completions nushell +numan completions powershell # writes ~/.numan/completions.ps1; dot-source from $PROFILE once + +# Advanced: print + redirect / pipe +numan completions bash --print > ~/.local/share/bash-completion/completions/numan +numan completions powershell --print | Add-Content -Encoding utf8 $PROFILE ``` -PowerShell completions are safe to place after other statements in `$PROFILE`. Prefer writing to a dedicated file and dot-sourcing if you want easier updates: - -```powershell -New-Item -ItemType Directory -Force -Path "$HOME\.numan" | Out-Null -numan completions powershell | Out-File -Encoding utf8 "$HOME\.numan\completions.ps1" -Add-Content -Path $PROFILE -Value '. $HOME\.numan\completions.ps1' -``` +PowerShell completions are safe to place after other statements in `$PROFILE`. --- @@ -374,7 +362,7 @@ Global flag: `--root ` — override the numan root directory (all commands | `numan nupm import [--as owner/name] [path]` | One-way import into numan | | `numan nupm import --manifest file.toml` | Batch import from manifest | | `numan nupm diff ` | Compare imported payload vs nupm source | -| `numan completions ` | Generate bash, fish, zsh, powershell, or nushell completions | +| `numan completions ` | Install shell completions (use `--print` to emit the script) | | `numan doctor [--scan] [--json]` | Diagnose root health and repair (use `--scan` for report-only) | ### Common flags (by command) diff --git a/src/cli.rs b/src/cli.rs index 37e20002..967cca45 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,7 +52,7 @@ pub enum Commands { Snapshot(cmd::snapshot::SnapshotCommands), /// Read-only nupm discovery and inspection Nupm(cmd::nupm::NupmArgs), - /// Generate shell completion scripts + /// Install shell completions (use `--print` to emit the script) Completions(cmd::completions::CompletionsArgs), /// Diagnose Numan root health and apply safe repairs (use `--scan` for report-only) Doctor(cmd::doctor::DoctorArgs), diff --git a/src/cmd/completions.rs b/src/cmd/completions.rs index c9d74af7..2564f158 100644 --- a/src/cmd/completions.rs +++ b/src/cmd/completions.rs @@ -1,16 +1,26 @@ -use anyhow::{Context, Result}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; use clap::{CommandFactory, ValueEnum}; use clap_complete::{generate, Shell}; use clap_complete_nushell::Nushell; use crate::cli::Cli; +use crate::util::atomic::write_bytes_atomic; +use crate::util::fs_safety::{assert_managed_file_owned, assert_not_symlink, OWNERSHIP_MARKER}; -/// Generate shell completion scripts +/// Install (default) or print shell completion scripts #[derive(clap::Parser)] pub struct CompletionsArgs { - /// Shell to generate completions for + /// Shell to install completions for #[arg(value_enum)] pub shell: CompletionShell, + + /// Print the script on stdout instead of installing it + /// (pipe-safe; copy-ready redirect hints go to stderr) + #[arg(long)] + pub print: bool, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -26,43 +36,159 @@ pub enum CompletionShell { } pub fn execute(args: &CompletionsArgs) -> Result<()> { - let script = generate_script(args.shell)?; - print!("{script}"); - // stderr so `numan completions … | Add-Content` / redirects stay script-only - eprint!("{}", install_hint(args.shell)); + if args.print { + let script = generate_script(args.shell)?; + print!("{script}"); + // stderr so redirects / pipes stay script-only + eprint!("{}", print_hint(args.shell)); + return Ok(()); + } + + let path = default_install_path(args.shell)?; + install_to(args.shell, &path)?; + println!( + "Installed {} completions to {}", + shell_label(args.shell), + path.display() + ); + if matches!(args.shell, CompletionShell::PowerShell) { + println!( + "Add to $PROFILE (once): . {}", + powershell_single_quote(&path) + ); + } + Ok(()) +} + +/// Quote a path for a copy-pasteable PowerShell single-quoted string. +/// +/// Always wraps in `'…'` and doubles embedded `'` as `''`, so spaces and +/// quotes in home paths cannot break the `. path` instruction. +fn powershell_single_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +fn shell_label(shell: CompletionShell) -> &'static str { + match shell { + CompletionShell::Bash => "bash", + CompletionShell::Fish => "fish", + CompletionShell::Zsh => "zsh", + CompletionShell::PowerShell => "powershell", + CompletionShell::Nushell => "nushell", + } +} + +/// Canonical install path for `numan completions `. +pub fn default_install_path(shell: CompletionShell) -> Result { + Ok(match shell { + CompletionShell::Bash => require_home_dir()? + .join(".local") + .join("share") + .join("bash-completion") + .join("completions") + .join("numan"), + CompletionShell::Zsh => require_home_dir()?.join(".zfunc").join("_numan"), + CompletionShell::Fish => fish_config_home()? + .join("fish") + .join("completions") + .join("numan.fish"), + CompletionShell::PowerShell => require_home_dir()?.join(".numan").join("completions.ps1"), + CompletionShell::Nushell => dirs::data_dir() + .context("Could not resolve data directory")? + .join("nushell") + .join("vendor") + .join("autoload") + .join("numan-completions.nu"), + }) +} + +/// Fish config root: `$XDG_CONFIG_HOME` when set, else `~/.config`. +/// +/// Matches Fish's discovery path. Do not use [`dirs::config_dir`] here: on +/// Windows that resolves to `%APPDATA%`, which Fish does not use by default. +fn fish_config_home() -> Result { + Ok(fish_config_home_with( + std::env::var_os("XDG_CONFIG_HOME").as_deref(), + &require_home_dir()?, + )) +} + +fn fish_config_home_with(xdg_config_home: Option<&OsStr>, home: &Path) -> PathBuf { + match xdg_config_home { + Some(p) if !p.is_empty() => PathBuf::from(p), + _ => home.join(".config"), + } +} + +fn require_home_dir() -> Result { + dirs::home_dir().context("Could not resolve home directory") +} + +/// Write the completion script to `path`, creating parent directories as needed. +/// +/// Existing destinations must already carry [`OWNERSHIP_MARKER`]; foreign files +/// are refused. The written content always begins with the ownership header. +pub fn install_to(shell: CompletionShell, path: &Path) -> Result<()> { + if path.file_name().is_none_or(|name| name.is_empty()) { + bail!("completion install path must be a file path"); + } + if let Some(parent) = path.parent() { + if parent.as_os_str().is_empty() { + bail!("completion install path must be a file path"); + } + if parent.exists() { + assert_not_symlink(parent, "completions directory")?; + } + } + if path.exists() { + assert_managed_file_owned(path)?; + } else { + assert_not_symlink(path, "completions file")?; + } + + let script = format!("{}{}", OWNERSHIP_MARKER, generate_script(shell)?); + write_bytes_atomic(path, script.as_bytes()) + .with_context(|| format!("Failed to write completions to {}", path.display()))?; Ok(()) } -/// Copy-ready install instructions for the generated completion script. +/// Copy-ready redirect / pipe hints shown with `--print`. /// -/// Written to stderr after the script so stdout remains safe to pipe into a -/// profile or completions file. -pub fn install_hint(shell: CompletionShell) -> String { +/// Written to stderr after the script so stdout remains safe to pipe. +pub fn print_hint(shell: CompletionShell) -> String { match shell { CompletionShell::Bash => "\ -# Install: -numan completions bash > ~/.local/share/bash-completion/completions/numan +# Prefer: numan completions bash +# Or redirect: +mkdir -p ~/.local/share/bash-completion/completions +numan completions bash --print > ~/.local/share/bash-completion/completions/numan " .to_string(), CompletionShell::Zsh => "\ -# Install: -numan completions zsh > ~/.zfunc/_numan +# Prefer: numan completions zsh +# Or redirect: +mkdir -p ~/.zfunc +numan completions zsh --print > ~/.zfunc/_numan " .to_string(), CompletionShell::Fish => "\ -# Install: -numan completions fish > ~/.config/fish/completions/numan.fish +# Prefer: numan completions fish +# Or redirect: +mkdir -p \"${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions\" +numan completions fish --print > \"${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions/numan.fish\" " .to_string(), CompletionShell::PowerShell => "\ -# Install (append to your PowerShell profile): -numan completions powershell | Add-Content -Encoding utf8 $PROFILE +# Prefer: numan completions powershell (writes ~/.numan/completions.ps1) +# Or append to your PowerShell profile: +numan completions powershell --print | Add-Content -Encoding utf8 $PROFILE " .to_string(), CompletionShell::Nushell => "\ -# Install (Nushell vendor autoload; restart nu or open a new session): +# Prefer: numan completions nushell +# Or manually: mkdir --all ($nu.data-dir | path join vendor/autoload) -numan completions nushell | save -f ($nu.data-dir | path join vendor/autoload/numan-completions.nu) +numan completions nushell --print | save -f ($nu.data-dir | path join vendor/autoload/numan-completions.nu) " .to_string(), } @@ -158,19 +284,127 @@ mod tests { } #[test] - fn install_hint_is_copy_ready_and_not_part_of_script() { + fn print_hint_is_copy_ready_and_not_part_of_script() { let script = generate_script(CompletionShell::PowerShell).expect("generate"); - let hint = install_hint(CompletionShell::PowerShell); + let hint = print_hint(CompletionShell::PowerShell); assert!( !script.contains("Add-Content"), - "install hint must not be mixed into the completion script" + "print hint must not be mixed into the completion script" + ); + assert!(hint.contains("numan completions powershell")); + assert!(hint.contains( + "numan completions powershell --print | Add-Content -Encoding utf8 $PROFILE" + )); + assert!(print_hint(CompletionShell::Bash) + .contains("mkdir -p ~/.local/share/bash-completion/completions")); + assert!(print_hint(CompletionShell::Bash).contains("numan completions bash --print")); + assert!(print_hint(CompletionShell::Zsh).contains("mkdir -p ~/.zfunc")); + assert!(print_hint(CompletionShell::Fish) + .contains("mkdir -p \"${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions\"")); + assert!( + print_hint(CompletionShell::Nushell).contains("vendor/autoload/numan-completions.nu") + ); + assert!(print_hint(CompletionShell::Nushell).contains("numan completions nushell --print")); + } + + #[test] + fn powershell_single_quote_is_copy_paste_safe() { + assert_eq!( + powershell_single_quote(Path::new(r"C:\Users\Alice\.numan\completions.ps1")), + r"'C:\Users\Alice\.numan\completions.ps1'" + ); + assert_eq!( + powershell_single_quote(Path::new(r"C:\Users\Alice Smith\.numan\completions.ps1")), + r"'C:\Users\Alice Smith\.numan\completions.ps1'" + ); + assert_eq!( + powershell_single_quote(Path::new(r"C:\Users\O'Brien\.numan\completions.ps1")), + r"'C:\Users\O''Brien\.numan\completions.ps1'" + ); + } + + #[test] + fn install_to_creates_missing_parent_directories() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("missing") + .join("nested") + .join("completions") + .join("numan"); + assert!(!path.parent().unwrap().exists()); + install_to(CompletionShell::Bash, &path).expect("install_to"); + assert!(path.is_file()); + let written = std::fs::read_to_string(&path).expect("read"); + assert!(written.starts_with(OWNERSHIP_MARKER)); + assert!(written.contains("_numan")); + } + + #[test] + fn install_to_refuses_foreign_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("numan"); + std::fs::write(&path, "# not owned by numan\n").expect("seed"); + let err = install_to(CompletionShell::Bash, &path).expect_err("foreign"); + assert!( + err.to_string().contains("managed-file drift"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "# not owned by numan\n" + ); + } + + #[test] + fn install_to_overwrites_owned_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("numan"); + std::fs::write(&path, format!("{OWNERSHIP_MARKER}# stale\n")).expect("seed"); + install_to(CompletionShell::Bash, &path).expect("overwrite"); + let written = std::fs::read_to_string(&path).expect("read"); + assert!(written.starts_with(OWNERSHIP_MARKER)); + assert!(written.contains("_numan")); + assert!(!written.contains("# stale")); + } + + #[test] + fn fish_config_home_respects_xdg_config_home() { + let home = Path::new("/home/alice"); + assert_eq!( + fish_config_home_with(None, home), + PathBuf::from("/home/alice/.config") + ); + assert_eq!( + fish_config_home_with(Some(OsStr::new("")), home), + PathBuf::from("/home/alice/.config") + ); + assert_eq!( + fish_config_home_with(Some(OsStr::new("/xdg/config")), home), + PathBuf::from("/xdg/config") + ); + } + + #[test] + fn default_install_paths_are_under_home_or_data() { + let bash = default_install_path(CompletionShell::Bash).expect("bash path"); + assert!( + bash.ends_with("bash-completion/completions/numan") + || bash.ends_with("bash-completion\\completions\\numan") + ); + let zsh = default_install_path(CompletionShell::Zsh).expect("zsh path"); + assert!(zsh.ends_with(".zfunc/_numan") || zsh.ends_with(".zfunc\\_numan")); + let fish = default_install_path(CompletionShell::Fish).expect("fish path"); + assert!( + fish.ends_with("fish/completions/numan.fish") + || fish.ends_with("fish\\completions\\numan.fish") ); - assert!(hint.contains("numan completions powershell | Add-Content -Encoding utf8 $PROFILE")); - assert!(install_hint(CompletionShell::Bash).contains("bash-completion/completions/numan")); - assert!(install_hint(CompletionShell::Zsh).contains("~/.zfunc/_numan")); - assert!(install_hint(CompletionShell::Fish).contains("numan.fish")); + let ps = default_install_path(CompletionShell::PowerShell).expect("ps path"); + assert!(ps.ends_with(".numan/completions.ps1") || ps.ends_with(".numan\\completions.ps1")); + let nu = default_install_path(CompletionShell::Nushell).expect("nu path"); assert!( - install_hint(CompletionShell::Nushell).contains("vendor/autoload/numan-completions.nu") + nu.ends_with("nushell/vendor/autoload/numan-completions.nu") + || nu.ends_with("nushell\\vendor\\autoload\\numan-completions.nu") ); } diff --git a/tests/completions_test.rs b/tests/completions_test.rs index aadea933..b9ef1951 100644 --- a/tests/completions_test.rs +++ b/tests/completions_test.rs @@ -72,11 +72,12 @@ fn powershell_completions_can_append_to_existing_profile() { } #[test] -fn powershell_install_hint_is_ready_to_copy() { - use numan_cli::cmd::completions::install_hint; +fn print_hint_is_ready_to_copy() { + use numan_cli::cmd::completions::print_hint; - let hint = install_hint(CompletionShell::PowerShell); + let hint = print_hint(CompletionShell::PowerShell); assert!(hint.contains("Add-Content -Encoding utf8 $PROFILE")); + assert!(hint.contains("numan completions powershell --print")); assert!( !generate_script(CompletionShell::PowerShell) .expect("generate") @@ -84,3 +85,27 @@ fn powershell_install_hint_is_ready_to_copy() { "hint must stay on stderr / separate from script stdout" ); } + +#[test] +fn install_to_creates_parent_dirs_for_each_shell() { + use numan_cli::cmd::completions::install_to; + use std::fs; + + let dir = tempfile::tempdir().expect("tempdir"); + let cases = [ + (CompletionShell::Bash, "bash/completions/numan"), + (CompletionShell::Zsh, "zsh/.zfunc/_numan"), + (CompletionShell::Fish, "fish/completions/numan.fish"), + (CompletionShell::PowerShell, "ps/.numan/completions.ps1"), + ( + CompletionShell::Nushell, + "nu/vendor/autoload/numan-completions.nu", + ), + ]; + for (shell, rel) in cases { + let path = dir.path().join(rel); + install_to(shell, &path).unwrap_or_else(|e| panic!("install {shell:?}: {e}")); + assert!(path.is_file(), "{shell:?} missing at {}", path.display()); + assert!(!fs::read_to_string(&path).expect("read").is_empty()); + } +}