Repository: astral-sh/uv (Rust)

Issue:
`uv tool` commands should warn if the executable directory isn't on the `PATH`
e.g. during `uv tool install` where the executable entry points are placed.

We can also consider some command in the style of `pipx ensurepath` that adds the executable directory the user's shell configuration.



Fix (patch):
diff --git a/Cargo.lock b/Cargo.lock
index 18d0531a6d628..ae51e1cf6f060 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4475,6 +4475,7 @@ dependencies = [
  "regex",
  "reqwest",
  "rustc-hash 2.0.0",
+ "same-file",
  "serde",
  "serde_json",
  "textwrap",
diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml
index 491b93f76947e..ce1f338946ca8 100644
--- a/crates/uv/Cargo.toml
+++ b/crates/uv/Cargo.toml
@@ -50,14 +50,15 @@ clap = { workspace = true, features = ["derive", "string", "wrap_help"] }
 flate2 = { workspace = true, default-features = false }
 fs-err = { workspace = true, features = ["tokio"] }
 futures = { workspace = true }
-indicatif = { workspace = true }
 indexmap = { workspace = true }
+indicatif = { workspace = true }
 itertools = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 owo-colors = { workspace = true }
 rayon = { workspace = true }
 regex = { workspace = true }
 rustc-hash = { workspace = true }
+same-file = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 textwrap = { workspace = true }
diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs
index b1a8651a970e4..44438e157c928 100644
--- a/crates/uv/src/commands/tool/install.rs
+++ b/crates/uv/src/commands/tool/install.rs
@@ -23,7 +23,7 @@ use uv_python::{
 };
 use uv_requirements::RequirementsSpecification;
 use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint};
-use uv_warnings::warn_user_once;
+use uv_warnings::{warn_user, warn_user_once};
 
 use crate::commands::project::{resolve_environment, sync_environment, update_environment};
 use crate::commands::reporters::PythonDownloadReporter;
@@ -31,6 +31,7 @@ use crate::commands::tool::common::resolve_requirements;
 use crate::commands::{ExitStatus, SharedState};
 use crate::printer::Printer;
 use crate::settings::ResolverInstallerSettings;
+use crate::shell::Shell;
 
 /// Install a tool.
 pub(crate) async fn install(
@@ -373,5 +374,75 @@ pub(crate) async fn install(
     );
     installed_tools.add_tool_receipt(&from.name, tool)?;
 
+    // If the executable directory isn't on the user's PATH, warn.
+    if !std::env::var_os("PATH")
+        .as_ref()
+        .iter()
+        .flat_map(std::env::split_paths)
+        .any(|path| same_file::is_same_file(&executable_directory, path).unwrap_or(false))
+    {
+        let dir = executable_directory.simplified_display();
+        let export = match Shell::from_env() {
+            None => None,
+            Some(Shell::Nushell) => None,
+            Some(Shell::Bash | Shell::Zsh) => Some(format!(
+                "export PATH=\"{}:$PATH\"",
+                backslash_escape(&dir.to_string()),
+            )),
+            Some(Shell::Fish) => Some(format!(
+                "fish_add_path \"{}\"",
+                backslash_escape(&dir.to_string()),
+            )),
+            Some(Shell::Csh) => Some(format!(
+                "setenv PATH \"{}:$PATH\"",
+                backslash_escape(&dir.to_string()),
+            )),
+            Some(Shell::Powershell) => Some(format!(
+                "$env:PATH = \"{};$env:PATH\"",
+                backtick_escape(&dir.to_string()),
+            )),
+            Some(Shell::Cmd) => Some(format!(
+                "set PATH=\"{};%PATH%\"",
+                backslash_escape(&dir.to_string()),
+            )),
+        };
+        if let Some(export) = export {
+            warn_user!(
+                "`{dir}` is not on your PATH. To use installed tools, run:\n  {}",
+                export.green()
+            );
+        } else {
+            warn_user!(
+                "`{dir}` is not on your PATH. To use installed tools, add the directory to your PATH",
+            );
+        }
+    }
+
     Ok(ExitStatus::Success)
 }
+
+/// Escape a string for use in a shell command by inserting backslashes.
+fn backslash_escape(s: &str) -> String {
+    let mut escaped = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '\\' | '"' => escaped.push('\\'),
+            _ => {}
+        }
+        escaped.push(c);
+    }
+    escaped
+}
+
+/// Escape a string for use in a `PowerShell` command by inserting backticks.
+fn backtick_escape(s: &str) -> String {
+    let mut escaped = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '\\' | '"' | '$' => escaped.push('`'),
+            _ => {}
+        }
+        escaped.push(c);
+    }
+    escaped
+}