Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 310 additions & 14 deletions wezterm-gui/src/agents/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ pub enum PackageCommand {
Search { query: String },
/// Install a package
Install { package: String },
/// Install a natural-language package profile
NaturalInstall {
query: String,
packages: Vec<String>,
confidence: u8,
reasoning: String,
},
/// Ask the user to clarify an ambiguous package request
Clarify {
query: String,
reason: String,
suggestions: Vec<String>,
},
/// Remove a package
Remove { package: String },
/// List installed packages
Expand All @@ -99,9 +112,14 @@ pub enum PackageCommand {
impl PackageCommand {
/// Parse a command string into a PackageCommand
pub fn parse(input: &str) -> Self {
let input_lower = input.to_lowercase();
let normalized = normalize_package_request(input);
let input_lower = normalized.to_lowercase();
let words: Vec<&str> = input.split_whitespace().collect();

if let Some(command) = parse_natural_install(input, &input_lower) {
return command;
}
Comment on lines +119 to +121
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Natural-language parsing currently preempts explicit install <package> commands.

Running parse_natural_install before explicit install parsing causes regressions like install docker-compose being interpreted as a profile instead of a direct package install.

Suggested fix
-        if let Some(command) = parse_natural_install(input, &input_lower) {
-            return command;
-        }
-
         // Install
         if input_lower.contains("install") {
             let package = words
                 .iter()
                 .skip_while(|&w| normalize_package_request(w).to_lowercase() != "install")
                 .nth(1)
                 .map(|s| s.to_string())
                 .or_else(|| words.last().map(|s| s.to_string()))
                 .unwrap_or_default();
             return Self::Install { package };
         }
+
+        if let Some(command) = parse_natural_install(input, &input_lower) {
+            return command;
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(command) = parse_natural_install(input, &input_lower) {
return command;
}
// Install
if input_lower.contains("install") {
let package = words
.iter()
.skip_while(|&w| normalize_package_request(w).to_lowercase() != "install")
.nth(1)
.map(|s| s.to_string())
.or_else(|| words.last().map(|s| s.to_string()))
.unwrap_or_default();
return Self::Install { package };
}
if let Some(command) = parse_natural_install(input, &input_lower) {
return command;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wezterm-gui/src/agents/package.rs` around lines 119 - 121, The
natural-language parser parse_natural_install currently runs before explicit
install handling and preempts commands like "install docker-compose"; fix this
by ensuring explicit install parsing runs first (or by adding a guard in
parse_natural_install to ignore inputs that begin with "install " or match the
explicit install pattern). Concretely, either move the
parse_natural_install(input, &input_lower) call to after the explicit install
parse function (the code path that handles "install <package>") or update
parse_natural_install to check input/input_lower and return None when the string
starts with "install " (or otherwise matches the explicit-install syntax), so
explicit installs are parsed correctly.


// Search
if input_lower.contains("search") || input_lower.contains("find package") {
let query = words
Expand All @@ -118,7 +136,7 @@ impl PackageCommand {
if input_lower.contains("install") {
let package = words
.iter()
.skip_while(|&w| w.to_lowercase() != "install")
.skip_while(|&w| normalize_package_request(w).to_lowercase() != "install")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling normalize_package_request(w) inside a loop is inefficient as it performs multiple string allocations, splitting, and joining for every word in the input. Since you only need to check if the word represents an 'install' command, a lighter word-level check is preferred.

                .skip_while(|&w| {
                    let w_lower = w.to_lowercase();
                    let trimmed = w_lower.trim_matches(|c: char| !c.is_alphanumeric() && c != '-');
                    !matches!(trimmed, "install" | "instal" | "isntall" | "isntal")
                })

.nth(1)
Comment on lines 136 to 140
.map(|s| s.to_string())
.or_else(|| words.last().map(|s| s.to_string()))
Expand Down Expand Up @@ -203,6 +221,150 @@ impl PackageCommand {
}
}

fn normalize_package_request(input: &str) -> String {
input
.to_lowercase()
.split_whitespace()
.map(|word| match word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-') {
"instal" | "isntall" | "isntal" => "install",
"pyhton" | "pythn" => "python",
"dockr" | "docer" => "docker",
"kubernets" | "kubernetess" | "k8s" => "kubernetes",
"ngnix" => "nginx",
"machne" => "machine",
"lerning" | "leaning" => "learning",
other => other,
})
.collect::<Vec<_>>()
.join(" ")
}

fn parse_natural_install(original: &str, input: &str) -> Option<PackageCommand> {
let is_install_request = input.contains("install")
|| input.contains("set up")
|| input.contains("setup")
|| input.contains("i need")
|| input.contains("something for");

if !is_install_request {
return None;
}
Comment on lines +243 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The is_install_request logic is prone to false positives because input.contains("install") matches "uninstall", and generic phrases like "i need" or "something for" don't distinguish between installation and removal. For example, "uninstall nginx" or "I need to remove docker" would incorrectly trigger the natural language installation profiles. Consider explicitly excluding removal intent.

Suggested change
let is_install_request = input.contains("install")
|| input.contains("set up")
|| input.contains("setup")
|| input.contains("i need")
|| input.contains("something for");
if !is_install_request {
return None;
}
let is_removal = input.contains("uninstall") || input.contains("remove");
let is_install_request = (input.contains("install")
|| input.contains("set up")
|| input.contains("setup")
|| input.contains("i need")
|| input.contains("something for")) && !is_removal;
if !is_install_request {
return None;
}


if input.contains("web server") {
return Some(PackageCommand::Clarify {
query: original.to_string(),
reason: "A web server could mean a reverse proxy, static file server, or application runtime.".to_string(),
suggestions: vec![
"install nginx for a common production reverse proxy".to_string(),
"install apache2 for a traditional HTTP server".to_string(),
"install caddy for automatic HTTPS".to_string(),
],
});
}

if input.contains("machine learning") || input.contains("ml") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The check input.contains("ml") is too broad and will trigger for any word containing these characters, such as "yaml", "html", "compiler", or "xml". This leads to incorrect triggering of the machine learning profile. Use a word-boundary check or verify that "ml" exists as a standalone word in the input.

Suggested change
if input.contains("machine learning") || input.contains("ml") {
if input.contains("machine learning") || input.split_whitespace().any(|w| w == "ml") {

return Some(PackageCommand::NaturalInstall {
Comment on lines +265 to +266
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

"ml" substring matching is too broad and causes false positives.

input.contains("ml") will match unrelated tokens (e.g., xml), incorrectly selecting the machine-learning profile.

Suggested fix
-    if input.contains("machine learning") || input.contains("ml") {
+    let tokens: std::collections::HashSet<&str> = input.split_whitespace().collect();
+    if input.contains("machine learning") || tokens.contains("ml") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if input.contains("machine learning") || input.contains("ml") {
return Some(PackageCommand::NaturalInstall {
let tokens: std::collections::HashSet<&str> = input.split_whitespace().collect();
if input.contains("machine learning") || tokens.contains("ml") {
return Some(PackageCommand::NaturalInstall {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wezterm-gui/src/agents/package.rs` around lines 265 - 266, The substring
check input.contains("ml") is too broad and causes false positives (e.g.,
matching "xml"); update the matching logic in the branch that returns
PackageCommand::NaturalInstall to only match standalone tokens for "ml" (and
case-insensitive variants) — for example, split or tokenize the input or use a
word-boundary regex to check for "\bml\b" (or equivalent) instead of
contains("ml"), keeping the existing check for "machine learning" and using the
same variable names (input and PackageCommand::NaturalInstall).

Comment on lines +265 to +266
query: original.to_string(),
packages: vec![
"python3",
"python3-pip",
"python3-venv",
"python3-numpy",
"python3-scipy",
"python3-pandas",
"python3-sklearn",
"jupyter-notebook",
]
Comment on lines +268 to +277
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Natural-install package lists are distro-specific but executed across all package managers.

Profiles use apt-style names (python3-venv, build-essential, docker.io) and are passed directly to pacman/dnf/brew/nix commands. This will fail on non-apt systems and is especially fragile for Nix attribute installs.

Also applies to: 293-299, 311-312, 452-466

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wezterm-gui/src/agents/package.rs` around lines 268 - 277, The package
vectors (e.g., the packages: vec![ "python3", "python3-pip", "python3-venv", ...
]) and the profile lists are using apt-style names but are passed unchanged to
other managers (pacman/dnf/brew/nix), which will break; replace these hard-coded
apt names with abstract package keys and implement a translation layer that maps
each abstract key to the correct package name per package manager (APT, DNF,
Pacman, Homebrew, Nix), add special handling for Nix to use attribute names
rather than apt strings, and call that translator wherever the "packages" vec or
profile package lists are used (e.g., the code that constructs install commands)
so the install commands use manager-specific names.

.into_iter()
.map(str::to_string)
.collect(),
confidence: 86,
reasoning: "I understood this as a Python-based machine learning workstation setup."
.to_string(),
Comment on lines +266 to +283
});
}

if input.contains("python development")
|| input.contains("python dev")
|| input.contains("python environment")
{
return Some(PackageCommand::NaturalInstall {
query: original.to_string(),
packages: vec![
"python3",
"python3-pip",
"python3-venv",
"python3-dev",
"build-essential",
]
.into_iter()
.map(str::to_string)
.collect(),
confidence: 91,
reasoning: "I understood this as a Python development environment with compiler headers, pip, and virtualenv support.".to_string(),
});
}

if input.contains("docker") && input.contains("kubernetes") {
return Some(PackageCommand::NaturalInstall {
query: original.to_string(),
packages: vec!["docker.io", "docker-compose-plugin", "kubectl"]
.into_iter()
.map(str::to_string)
.collect(),
confidence: 82,
reasoning: "I understood this as container tooling plus the Kubernetes command-line client.".to_string(),
});
}

if input.contains("nginx") {
return Some(PackageCommand::NaturalInstall {
query: original.to_string(),
packages: vec!["nginx"].into_iter().map(str::to_string).collect(),
confidence: 94,
reasoning: "I understood this as a request for the nginx web server.".to_string(),
});
}

if input.contains("python") {
return Some(PackageCommand::NaturalInstall {
query: original.to_string(),
packages: vec!["python3", "python3-pip", "python3-venv"]
.into_iter()
.map(str::to_string)
.collect(),
confidence: 78,
reasoning: "I understood this as a general Python runtime request.".to_string(),
});
}

if input.contains("docker") {
return Some(PackageCommand::NaturalInstall {
query: original.to_string(),
packages: vec!["docker.io", "docker-compose-plugin"]
.into_iter()
.map(str::to_string)
.collect(),
confidence: 84,
reasoning: "I understood this as a Docker engine and Compose plugin setup.".to_string(),
});
}

if input.contains("something for") || input.contains("i need") || input.contains("set up") {
return Some(PackageCommand::Clarify {
query: original.to_string(),
reason: "The request describes a goal, but not enough constraints to choose packages safely.".to_string(),
suggestions: vec![
"name the language or framework".to_string(),
"say whether this is for a desktop, server, or container host".to_string(),
"ask for a known profile such as machine learning or python development".to_string(),
],
});
}

None
}

/// Package agent for package management
pub struct PackageAgent {
capabilities: Vec<AgentCapability>,
Expand Down Expand Up @@ -271,31 +433,69 @@ impl PackageAgent {

/// Install a package (returns command to run, requires confirmation)
fn install_package(&self, package: &str) -> AgentResponse {
self.install_packages(&[package.to_string()], None, None, None)
}

/// Install packages (returns command to run, requires confirmation)
fn install_packages(
&self,
packages: &[String],
confidence: Option<u8>,
reasoning: Option<&str>,
query: Option<&str>,
) -> AgentResponse {
if packages.is_empty() || packages.iter().any(|p| p.trim().is_empty()) {
return AgentResponse::error("No package name was provided".to_string());
}

let package_list = packages.join(" ");
let cmd_str = match self.package_manager {
Comment on lines +447 to 452
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate package identifiers with an allow-list before command construction.

packages.join(" ") is used to build executable command text without character allow-list validation. Add strict package-name validation before command generation.

Suggested fix
     fn install_packages(
         &self,
         packages: &[String],
         confidence: Option<u8>,
         reasoning: Option<&str>,
         query: Option<&str>,
     ) -> AgentResponse {
         if packages.is_empty() || packages.iter().any(|p| p.trim().is_empty()) {
             return AgentResponse::error("No package name was provided".to_string());
         }
+        if packages
+            .iter()
+            .any(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_'))
+        {
+            return AgentResponse::error("Invalid package name format".to_string());
+        }
 
         let package_list = packages.join(" ");

As per coding guidelines, "Use allow-lists for input validation to prevent injection attacks".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if packages.is_empty() || packages.iter().any(|p| p.trim().is_empty()) {
return AgentResponse::error("No package name was provided".to_string());
}
let package_list = packages.join(" ");
let cmd_str = match self.package_manager {
if packages.is_empty() || packages.iter().any(|p| p.trim().is_empty()) {
return AgentResponse::error("No package name was provided".to_string());
}
if packages
.iter()
.any(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_'))
{
return AgentResponse::error("Invalid package name format".to_string());
}
let package_list = packages.join(" ");
let cmd_str = match self.package_manager {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wezterm-gui/src/agents/package.rs` around lines 447 - 452, The code builds a
shell command from packages (packages, package_list) without validating
identifiers which risks injection; before creating cmd_str based on
self.package_manager, validate each package name against a strict allow-list
(e.g., a regex that permits only expected characters like ASCII alphanumerics,
hyphen, underscore, dot—and optionally version separators) and reject any
package that fails by returning AgentResponse::error with a clear message; apply
this check to the packages vector before packages.join(" ") and ensure different
package manager flavors (self.package_manager) enforce any manager-specific
constraints.

PackageManager::Apt => format!("sudo apt install -y {}", package),
PackageManager::Pacman => format!("sudo pacman -S {}", package),
PackageManager::Dnf => format!("sudo dnf install -y {}", package),
PackageManager::Yum => format!("sudo yum install -y {}", package),
PackageManager::Zypper => format!("sudo zypper install -y {}", package),
PackageManager::Brew => format!("brew install {}", package),
PackageManager::Nix => format!("nix-env -iA nixpkgs.{}", package),
PackageManager::Apt => format!("sudo apt install -y {}", package_list),
PackageManager::Pacman => format!("sudo pacman -S {}", package_list),
PackageManager::Dnf => format!("sudo dnf install -y {}", package_list),
PackageManager::Yum => format!("sudo yum install -y {}", package_list),
PackageManager::Zypper => format!("sudo zypper install -y {}", package_list),
PackageManager::Brew => format!("brew install {}", package_list),
PackageManager::Nix => {
let attrs = packages
.iter()
.map(|package| format!("nixpkgs.{}", package))
.collect::<Vec<_>>()
.join(" ");
format!("nix-env -iA {}", attrs)
}
PackageManager::Unknown => {
return AgentResponse::error("No package manager detected".to_string());
}
};

// Return the command to execute - requires user confirmation
let intro = match (query, confidence, reasoning) {
(Some(query), Some(confidence), Some(reasoning)) => format!(
"I understood you want: {}\nReasoning: {}\nConfidence: {}%\nPackages: {}\n\n",
query, reasoning, confidence, package_list
),
_ => String::new(),
};

AgentResponse::success(format!(
"To install '{}', run:\n\n {}\n\nThis requires confirmation before execution.",
package, cmd_str
"{}To install '{}', run:\n\n {}\n\nThis requires confirmation before execution.",
intro, package_list, cmd_str
))
.with_commands(vec![cmd_str])
.with_suggestions(vec![
format!("search {}", package),
format!("info {}", package),
format!("search {}", packages[0]),
format!("info {}", packages[0]),
])
}

fn clarify_install(&self, query: &str, reason: &str, suggestions: Vec<String>) -> AgentResponse {
AgentResponse::success(format!(
"I understood the request, but need clarification before choosing packages.\nRequest: {}\nReasoning: {}\nConfidence: 45%",
query, reason
))
.with_suggestions(suggestions)
}

/// Remove a package (returns command to run, requires confirmation)
fn remove_package(&self, package: &str) -> AgentResponse {
let cmd_str = match self.package_manager {
Expand Down Expand Up @@ -567,6 +767,7 @@ impl Agent for PackageAgent {
let cmd_lower = request.command.to_lowercase();
cmd_lower.contains("package")
|| cmd_lower.contains("install")
|| cmd_lower.contains("instal")
|| cmd_lower.contains("uninstall")
|| cmd_lower.contains("upgrade")
|| cmd_lower.contains("apt ")
Expand All @@ -580,6 +781,22 @@ impl Agent for PackageAgent {
match command {
PackageCommand::Search { query } => self.search_packages(&query),
PackageCommand::Install { package } => self.install_package(&package),
PackageCommand::NaturalInstall {
query,
packages,
confidence,
reasoning,
} => self.install_packages(
&packages,
Some(confidence),
Some(&reasoning),
Some(&query),
),
PackageCommand::Clarify {
query,
reason,
suggestions,
} => self.clarify_install(&query, &reason, suggestions),
PackageCommand::Remove { package } => self.remove_package(&package),
PackageCommand::ListInstalled { filter } => self.list_installed(filter.as_deref()),
PackageCommand::CheckUpdates => self.check_updates(),
Expand Down Expand Up @@ -626,6 +843,85 @@ mod tests {
));
}

#[test]
fn test_parse_machine_learning_profile() {
assert!(matches!(
PackageCommand::parse("install something for machine learning"),
PackageCommand::NaturalInstall { packages, confidence, .. }
if confidence >= 80 && packages.contains(&"python3-sklearn".to_string())
));
}

#[test]
fn test_parse_web_server_ambiguity() {
assert!(matches!(
PackageCommand::parse("I need a web server"),
PackageCommand::Clarify { suggestions, .. } if suggestions.len() >= 3
));
}

#[test]
fn test_parse_python_development_environment() {
assert!(matches!(
PackageCommand::parse("set up python development environment"),
PackageCommand::NaturalInstall { packages, confidence, .. }
if confidence >= 90 && packages.contains(&"python3-dev".to_string())
));
}

#[test]
fn test_parse_docker_and_kubernetes() {
assert!(matches!(
PackageCommand::parse("install docker and kubernetes"),
PackageCommand::NaturalInstall { packages, .. }
if packages == vec!["docker.io".to_string(), "docker-compose-plugin".to_string(), "kubectl".to_string()]
));
}

#[test]
fn test_typo_install_python() {
assert!(matches!(
PackageCommand::parse("instal pyhton"),
PackageCommand::NaturalInstall { packages, .. }
if packages.contains(&"python3".to_string())
));
}

#[test]
fn test_typo_install_nginx() {
assert!(matches!(
PackageCommand::parse("instal ngnix"),
PackageCommand::NaturalInstall { packages, confidence, .. }
if confidence >= 90 && packages == vec!["nginx".to_string()]
));
}

#[test]
fn test_unknown_goal_requires_clarification() {
assert!(matches!(
PackageCommand::parse("set up something useful"),
PackageCommand::Clarify { reason, .. } if reason.contains("not enough constraints")
));
}

#[test]
fn test_general_python_profile() {
assert!(matches!(
PackageCommand::parse("I need python"),
PackageCommand::NaturalInstall { packages, confidence, .. }
if confidence >= 70 && packages.contains(&"python3-venv".to_string())
));
}

#[test]
fn test_general_docker_profile() {
assert!(matches!(
PackageCommand::parse("set up docker"),
PackageCommand::NaturalInstall { packages, confidence, .. }
if confidence >= 80 && packages.contains(&"docker-compose-plugin".to_string())
));
}

#[test]
fn test_parse_check_updates() {
assert!(matches!(
Expand Down
Loading
Loading