use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_global_skill_path, resolve_project_path,};use crate::{AgentTool, ToolCallEventStream, ToolInput};use agent_client_protocol::schema::v1 as acp;use anyhow::{Context as _, Result, anyhow};use fs::Fs;use futures::StreamExt as _;use gpui::{App, Entity, SharedString, Task};use project::{Project, ProjectPath, WorktreeSettings};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use std::fmt::Write;use std::path::Path;use std::sync::Arc;use util::markdown::MarkdownInlineCode; /// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.////// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.#[derive(Debug, Serialize, Deserialize, JsonSchema)]pub struct ListDirectoryToolInput { /// The fully-qualified path of the directory to list in the project. /// /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill directory under `~/.agents/skills`. /// /// <example> /// If the project has the following root directories: /// /// - directory1 /// - directory2 /// /// You can list the contents of `directory1` by using the path `directory1`. /// </example> /// /// <example> /// If the project has the following root directories: /// /// - foo /// - bar /// /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. /// </example> /// /// <example> /// To list a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// </example> pub path: String,} pub struct ListDirectoryTool { project: Entity<Project>,} impl ListDirectoryTool { pub fn new(project: Entity<Project>) -> Self { Self { project } } /// List the contents of a directory under the global skills tree directly /// via the filesystem. Used for skill resources that live outside any /// worktree. async fn list_global_skill_directory( canonical_path: &Path, fs: &dyn Fs, input_path: &str, ) -> Result<String, String> { let mut entries = fs .read_dir(canonical_path) .await .map_err(|err| err.to_string())?; let mut folders = Vec::new(); let mut files = Vec::new(); while let Some(entry) = entries.next().await { let Ok(entry_path) = entry else { continue; }; let display = entry_path.to_string_lossy().into_owned(); // Use a metadata call rather than `is_dir` so we can short-circuit // on missing entries (e.g. dangling symlinks). let Ok(Some(metadata)) = fs.metadata(&entry_path).await else { continue; }; if metadata.is_dir { folders.push(display); } else { files.push(display); } } folders.sort(); files.sort(); let mut output = String::new(); if !folders.is_empty() { writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); } if !files.is_empty() { writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); } if output.is_empty() { writeln!(output, "{input_path} is empty.").unwrap(); } Ok(output) } fn build_directory_output( project: &Entity<Project>, project_path: &ProjectPath, input_path: &str, cx: &App, ) -> Result<String> { let worktree = project .read(cx) .worktree_for_id(project_path.worktree_id, cx) .with_context(|| format!("{input_path} is not in a known worktree"))?; let global_settings = WorktreeSettings::get_global(cx); let worktree_settings = WorktreeSettings::get(Some(project_path.into()), cx); let worktree_snapshot = worktree.read(cx).snapshot(); let worktree_root_name = worktree.read(cx).root_name(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { return Err(anyhow!("Path not found: {}", input_path)); }; if !entry.is_dir() { return Err(anyhow!("{input_path} is not a directory.")); } let mut folders = Vec::new(); let mut files = Vec::new(); for entry in worktree_snapshot.child_entries(&project_path.path) { // Skip private and excluded files and directories if global_settings.is_path_private(&entry.path) || global_settings.is_path_excluded(&entry.path) { continue; } let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into(); if worktree_settings.is_path_excluded(&project_path.path) || worktree_settings.is_path_private(&project_path.path) { continue; } let full_path = worktree_root_name .join(&entry.path) .display(worktree_snapshot.path_style()) .into_owned(); if entry.is_dir() { folders.push(full_path); } else { files.push(full_path); } } let mut output = String::new(); if !folders.is_empty() { writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); } if !files.is_empty() { writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); } if output.is_empty() { writeln!(output, "{input_path} is empty.").unwrap(); } Ok(output) }} impl AgentTool for ListDirectoryTool { type Input = ListDirectoryToolInput; type Output = String; const NAME: &'static str = "list_directory"; fn kind() -> acp::ToolKind { acp::ToolKind::Read } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { if let Ok(input) = input { let path = MarkdownInlineCode(&input.path); format!("List the {path} directory's contents").into() } else { "List directory".into() } } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input .recv() .await .map_err(|e| e.to_string())?; // Sometimes models will return these even though we tell it to give a path and not a glob. // When this happens, just list the root worktree directories. if matches!(input.path.as_str(), "." | "" | "./" | "*") { let output = project.read_with(cx, |project, cx| { project .worktrees(cx) .filter_map(|worktree| { let worktree = worktree.read(cx); let root_entry = worktree.root_entry()?; if root_entry.is_dir() { Some(root_entry.path.display(worktree.path_style())) } else { None } }) .collect::<Vec<_>>() .join("\n") }); return Ok(output); } let fs = project.read_with(cx, |project, _cx| project.fs().clone()); // Fast path: a global skill resource lives outside any worktree, so // standard project-path resolution would refuse it. If the path // expands and resolves under the global skills tree, list it directly. if let Some(skill_path) = resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await { return Self::list_global_skill_directory( &skill_path, fs.as_ref(), &input.path, ) .await; } let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let (project_path, symlink_canonical_target) = project.read_with(cx, |project, cx| -> anyhow::Result<_> { let resolved = resolve_project_path(project, &input.path, &canonical_roots, cx)?; Ok(match resolved { ResolvedProjectPath::Safe(path) => (path, None), ResolvedProjectPath::SymlinkEscape { project_path, canonical_target, } => (project_path, Some(canonical_target)), }) }).map_err(|e| e.to_string())?; // Check settings exclusions synchronously project.read_with(cx, |project, cx| { let worktree = project .worktree_for_id(project_path.worktree_id, cx) .with_context(|| { format!("{} is not in a known worktree", &input.path) })?; let global_settings = WorktreeSettings::get_global(cx); if global_settings.is_path_excluded(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", &input.path ); } if global_settings.is_path_private(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's global `private_files` setting: {}", &input.path ); } let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); if worktree_settings.is_path_excluded(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's worktree `file_scan_exclusions` setting: {}", &input.path ); } if worktree_settings.is_path_private(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", &input.path ); } let worktree_snapshot = worktree.read(cx).snapshot(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { anyhow::bail!("Path not found: {}", input.path); }; if !entry.is_dir() { anyhow::bail!("{} is not a directory.", input.path); } anyhow::Ok(()) }).map_err(|e| e.to_string())?; if let Some(canonical_target) = &symlink_canonical_target { let authorize = cx.update(|cx| { authorize_symlink_access( Self::NAME, &input.path, canonical_target, &event_stream, cx, ) }); authorize.await.map_err(|e| e.to_string())?; } let list_path = input.path; cx.update(|cx| { Self::build_directory_output(&project, &project_path, &list_path, cx) }).map_err(|e| e.to_string()) }) }} #[cfg(test)]mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; use indoc::indoc; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use std::path::PathBuf; use util::path; fn platform_paths(path_str: &str) -> String { if cfg!(target_os = "windows") { path_str.replace("/", "\\") } else { path_str.to_string() } } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[gpui::test] async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "src": { "main.rs": "fn main() {}", "lib.rs": "pub fn hello() {}", "models": { "user.rs": "struct User {}", "post.rs": "struct Post {}" }, "utils": { "helper.rs": "pub fn help() {}" } }, "tests": { "integration_test.rs": "#[test] fn test() {}" }, "README.md": "# Project", "Cargo.toml": "[package]" }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let tool = Arc::new(ListDirectoryTool::new(project)); // Test listing root directory let input = ListDirectoryToolInput { path: "project".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!( output, platform_paths(indoc! {" # Folders: project/src project/tests # Files: project/Cargo.toml project/README.md "}) ); // Test listing src directory let input = ListDirectoryToolInput { path: "project/src".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!( output, platform_paths(indoc! {" # Folders: project/src/models project/src/utils # Files: project/src/lib.rs project/src/main.rs "}) ); // Test listing directory with only files let input = ListDirectoryToolInput { path: "project/tests".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert!(!output.contains("# Folders:")); assert!(output.contains("# Files:")); assert!(output.contains(&platform_paths("project/tests/integration_test.rs"))); } #[gpui::test] async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "empty_dir": {} }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let tool = Arc::new(ListDirectoryTool::new(project)); let input = ListDirectoryToolInput { path: "project/empty_dir".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!(output, "project/empty_dir is empty.\n"); } #[gpui::test]Lines 1–500 of 1253. Load more
use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_global_skill_path, resolve_project_path, }; use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, anyhow}; use fs::Fs; use futures::StreamExt as _; use gpui::{App, Entity, SharedString, Task}; use project::{Project, ProjectPath, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::fmt::Write; use std::path::Path; use std::sync::Arc; use util::markdown::MarkdownInlineCode; /// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. /// /// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ListDirectoryToolInput { /// The fully-qualified path of the directory to list in the project. /// /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill directory under `~/.agents/skills`. /// /// <example> /// If the project has the following root directories: /// /// - directory1 /// - directory2 /// /// You can list the contents of `directory1` by using the path `directory1`. /// </example> /// /// <example> /// If the project has the following root directories: /// /// - foo /// - bar /// /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. /// </example> /// /// <example> /// To list a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// </example> pub path: String, } pub struct ListDirectoryTool { project: Entity<Project>, } impl ListDirectoryTool { pub fn new(project: Entity<Project>) -> Self { Self { project } } /// List the contents of a directory under the global skills tree directly /// via the filesystem. Used for skill resources that live outside any /// worktree. async fn list_global_skill_directory( canonical_path: &Path, fs: &dyn Fs, input_path: &str, ) -> Result<String, String> { let mut entries = fs .read_dir(canonical_path) .await .map_err(|err| err.to_string())?; let mut folders = Vec::new(); let mut files = Vec::new(); while let Some(entry) = entries.next().await { let Ok(entry_path) = entry else { continue; }; let display = entry_path.to_string_lossy().into_owned(); // Use a metadata call rather than `is_dir` so we can short-circuit // on missing entries (e.g. dangling symlinks). let Ok(Some(metadata)) = fs.metadata(&entry_path).await else { continue; }; if metadata.is_dir { folders.push(display); } else { files.push(display); } } folders.sort(); files.sort(); let mut output = String::new(); if !folders.is_empty() { writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); } if !files.is_empty() { writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); } if output.is_empty() { writeln!(output, "{input_path} is empty.").unwrap(); } Ok(output) } fn build_directory_output( project: &Entity<Project>, project_path: &ProjectPath, input_path: &str, cx: &App, ) -> Result<String> { let worktree = project .read(cx) .worktree_for_id(project_path.worktree_id, cx) .with_context(|| format!("{input_path} is not in a known worktree"))?; let global_settings = WorktreeSettings::get_global(cx); let worktree_settings = WorktreeSettings::get(Some(project_path.into()), cx); let worktree_snapshot = worktree.read(cx).snapshot(); let worktree_root_name = worktree.read(cx).root_name(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { return Err(anyhow!("Path not found: {}", input_path)); }; if !entry.is_dir() { return Err(anyhow!("{input_path} is not a directory.")); } let mut folders = Vec::new(); let mut files = Vec::new(); for entry in worktree_snapshot.child_entries(&project_path.path) { // Skip private and excluded files and directories if global_settings.is_path_private(&entry.path) || global_settings.is_path_excluded(&entry.path) { continue; } let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into(); if worktree_settings.is_path_excluded(&project_path.path) || worktree_settings.is_path_private(&project_path.path) { continue; } let full_path = worktree_root_name .join(&entry.path) .display(worktree_snapshot.path_style()) .into_owned(); if entry.is_dir() { folders.push(full_path); } else { files.push(full_path); } } let mut output = String::new(); if !folders.is_empty() { writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); } if !files.is_empty() { writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); } if output.is_empty() { writeln!(output, "{input_path} is empty.").unwrap(); } Ok(output) } } impl AgentTool for ListDirectoryTool { type Input = ListDirectoryToolInput; type Output = String; const NAME: &'static str = "list_directory"; fn kind() -> acp::ToolKind { acp::ToolKind::Read } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { if let Ok(input) = input { let path = MarkdownInlineCode(&input.path); format!("List the {path} directory's contents").into() } else { "List directory".into() } } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input .recv() .await .map_err(|e| e.to_string())?; // Sometimes models will return these even though we tell it to give a path and not a glob. // When this happens, just list the root worktree directories. if matches!(input.path.as_str(), "." | "" | "./" | "*") { let output = project.read_with(cx, |project, cx| { project .worktrees(cx) .filter_map(|worktree| { let worktree = worktree.read(cx); let root_entry = worktree.root_entry()?; if root_entry.is_dir() { Some(root_entry.path.display(worktree.path_style())) } else { None } }) .collect::<Vec<_>>() .join("\n") }); return Ok(output); } let fs = project.read_with(cx, |project, _cx| project.fs().clone()); // Fast path: a global skill resource lives outside any worktree, so // standard project-path resolution would refuse it. If the path // expands and resolves under the global skills tree, list it directly. if let Some(skill_path) = resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await { return Self::list_global_skill_directory( &skill_path, fs.as_ref(), &input.path, ) .await; } let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let (project_path, symlink_canonical_target) = project.read_with(cx, |project, cx| -> anyhow::Result<_> { let resolved = resolve_project_path(project, &input.path, &canonical_roots, cx)?; Ok(match resolved { ResolvedProjectPath::Safe(path) => (path, None), ResolvedProjectPath::SymlinkEscape { project_path, canonical_target, } => (project_path, Some(canonical_target)), }) }).map_err(|e| e.to_string())?; // Check settings exclusions synchronously project.read_with(cx, |project, cx| { let worktree = project .worktree_for_id(project_path.worktree_id, cx) .with_context(|| { format!("{} is not in a known worktree", &input.path) })?; let global_settings = WorktreeSettings::get_global(cx); if global_settings.is_path_excluded(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", &input.path ); } if global_settings.is_path_private(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's global `private_files` setting: {}", &input.path ); } let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); if worktree_settings.is_path_excluded(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's worktree `file_scan_exclusions` setting: {}", &input.path ); } if worktree_settings.is_path_private(&project_path.path) { anyhow::bail!( "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", &input.path ); } let worktree_snapshot = worktree.read(cx).snapshot(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { anyhow::bail!("Path not found: {}", input.path); }; if !entry.is_dir() { anyhow::bail!("{} is not a directory.", input.path); } anyhow::Ok(()) }).map_err(|e| e.to_string())?; if let Some(canonical_target) = &symlink_canonical_target { let authorize = cx.update(|cx| { authorize_symlink_access( Self::NAME, &input.path, canonical_target, &event_stream, cx, ) }); authorize.await.map_err(|e| e.to_string())?; } let list_path = input.path; cx.update(|cx| { Self::build_directory_output(&project, &project_path, &list_path, cx) }).map_err(|e| e.to_string()) }) } } #[cfg(test)] mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; use indoc::indoc; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use std::path::PathBuf; use util::path; fn platform_paths(path_str: &str) -> String { if cfg!(target_os = "windows") { path_str.replace("/", "\\") } else { path_str.to_string() } } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[gpui::test] async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "src": { "main.rs": "fn main() {}", "lib.rs": "pub fn hello() {}", "models": { "user.rs": "struct User {}", "post.rs": "struct Post {}" }, "utils": { "helper.rs": "pub fn help() {}" } }, "tests": { "integration_test.rs": "#[test] fn test() {}" }, "README.md": "# Project", "Cargo.toml": "[package]" }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let tool = Arc::new(ListDirectoryTool::new(project)); // Test listing root directory let input = ListDirectoryToolInput { path: "project".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!( output, platform_paths(indoc! {" # Folders: project/src project/tests # Files: project/Cargo.toml project/README.md "}) ); // Test listing src directory let input = ListDirectoryToolInput { path: "project/src".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!( output, platform_paths(indoc! {" # Folders: project/src/models project/src/utils # Files: project/src/lib.rs project/src/main.rs "}) ); // Test listing directory with only files let input = ListDirectoryToolInput { path: "project/tests".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert!(!output.contains("# Folders:")); assert!(output.contains("# Files:")); assert!(output.contains(&platform_paths("project/tests/integration_test.rs"))); } #[gpui::test] async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "empty_dir": {} }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let tool = Arc::new(ListDirectoryTool::new(project)); let input = ListDirectoryToolInput { path: "project/empty_dir".into(), }; let output = cx .update(|cx| { tool.clone().run( ToolInput::resolved(input), ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!(output, "project/empty_dir is empty.\n"); } #[gpui::test]