diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs
index ca61a9e632e..dcd051c2a72 100644
--- a/crates/agent/src/tools/create_directory_tool.rs
+++ b/crates/agent/src/tools/create_directory_tool.rs
@@ -1,6 +1,6 @@
use super::tool_permissions::{
authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,
- sensitive_settings_kind,
+ resolve_creatable_global_skill_path, sensitive_settings_kind,
};
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
@@ -22,6 +22,7 @@ use std::path::Path;
/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
///
/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project.
+/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CreateDirectoryToolInput {
/// The path of the new directory.
@@ -34,6 +35,10 @@ pub struct CreateDirectoryToolInput {
///
/// You can create a new directory by providing a path of "directory1/new_directory"
///
+ ///
+ ///
+ /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`.
+ ///
pub path: String,
}
@@ -144,6 +149,21 @@ impl AgentTool for CreateDirectoryTool {
authorize.await.map_err(|e| e.to_string())?;
}
+ if let Some(global_skill_directory) =
+ resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await
+ {
+ futures::select! {
+ result = fs.create_dir(&global_skill_directory).fuse() => {
+ result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
+ }
+ _ = event_stream.cancelled_by_user().fuse() => {
+ return Err("Create directory cancelled by user".to_string());
+ }
+ }
+
+ return Ok(format!("Created directory {destination_path}"));
+ }
+
let create_entry = project.update(cx, |project, cx| {
match project.find_project_path(&input.path, cx) {
Some(project_path) => Ok(project.create_entry(project_path, true, cx)),
@@ -190,6 +210,96 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_create_directory_allows_global_skill_directory(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root/project"), json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
+ cx.executor().run_until_parked();
+
+ let tool = Arc::new(CreateDirectoryTool::new(project));
+ let input_path = PathBuf::from("~")
+ .join(".agents")
+ .join("skills")
+ .join("my-skill")
+ .to_string_lossy()
+ .into_owned();
+ let created_path = agent_skills::global_skills_dir().join("my-skill");
+
+ let (event_stream, mut event_rx) = ToolCallEventStream::test();
+ let task = cx.update(|cx| {
+ tool.run(
+ ToolInput::resolved(CreateDirectoryToolInput { path: input_path }),
+ event_stream,
+ cx,
+ )
+ });
+
+ let auth = event_rx.expect_authorization().await;
+ let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
+ assert!(
+ title.contains("agent skills"),
+ "Authorization title should mention agent skills, got: {title}",
+ );
+ auth.response
+ .send(acp_thread::SelectedPermissionOutcome::new(
+ acp::PermissionOptionId::new("allow"),
+ acp::PermissionOptionKind::AllowOnce,
+ ))
+ .expect("authorization response should send");
+
+ let result = task.await;
+ assert!(
+ result.is_ok(),
+ "Tool should create global skill directory: {result:?}"
+ );
+ assert!(fs.is_dir(&created_path).await);
+ }
+
+ #[gpui::test]
+ async fn test_create_directory_rejects_other_global_paths(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root/project"), json!({})).await;
+ let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
+ cx.executor().run_until_parked();
+
+ let tool = Arc::new(CreateDirectoryTool::new(project));
+ let outside_path = agent_skills::global_skills_dir()
+ .parent()
+ .expect("global skills directory should have a parent")
+ .join("not-skills");
+
+ let (event_stream, mut event_rx) = ToolCallEventStream::test();
+ let result = cx
+ .update(|cx| {
+ tool.run(
+ ToolInput::resolved(CreateDirectoryToolInput {
+ path: outside_path.to_string_lossy().into_owned(),
+ }),
+ event_stream,
+ cx,
+ )
+ })
+ .await;
+
+ assert!(
+ result.is_err(),
+ "Tool should reject paths outside the project and global skills directory"
+ );
+ assert!(!fs.is_dir(&outside_path).await);
+ assert!(
+ !matches!(
+ event_rx.try_recv(),
+ Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
+ ),
+ "Non-skill global path should not emit an agent-skills authorization prompt",
+ );
+ }
+
#[gpui::test]
async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs
index 60d93111316..82c6463dd11 100644
--- a/crates/agent/src/tools/edit_file_tool.rs
+++ b/crates/agent/src/tools/edit_file_tool.rs
@@ -26,6 +26,8 @@ const DEFAULT_UI_TEXT: &str = "Editing file";
/// Before using this tool, use the `read_file` tool to understand the file's contents and context.
/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead.
///
+/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
+///
/// `read_file` prefixes each line of its output with a line number right-aligned in a
/// 6-character field followed by a single tab, then the line's actual content. When you
/// derive `old_text` or `new_text` from that output, strip this prefix and keep only what
@@ -35,7 +37,7 @@ const DEFAULT_UI_TEXT: &str = "Editing file";
pub struct EditFileToolInput {
/// The full path of the file to edit in the project.
///
- /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
+ /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories, unless it's a global agent skill under `~/.agents/skills`.
///
/// The following examples assume we have two root directories in the project:
/// - /a/b/backend
@@ -50,6 +52,10 @@ pub struct EditFileToolInput {
///
/// `frontend/db.js`
///
+ ///
+ ///
+ /// To edit a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`.
+ ///
pub path: PathBuf,
/// List of edit operations to apply sequentially.
@@ -464,6 +470,63 @@ mod tests {
assert_eq!(input_path, None);
}
+ #[gpui::test]
+ async fn test_streaming_edit_global_skill_file(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/root"), json!({})).await;
+ let skill_dir = agent_skills::global_skills_dir().join("my-skill");
+ fs.insert_tree(&skill_dir, json!({ "SKILL.md": "old content\n" }))
+ .await;
+ let (edit_tool, _project, _action_log, fs, _thread) =
+ setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
+
+ let input_path = PathBuf::from("~")
+ .join(".agents")
+ .join("skills")
+ .join("my-skill")
+ .join("SKILL.md");
+ let skill_file = agent_skills::global_skills_dir()
+ .join("my-skill")
+ .join("SKILL.md");
+
+ let (event_stream, mut event_rx) = ToolCallEventStream::test();
+ let task = cx.update(|cx| {
+ edit_tool.clone().run(
+ ToolInput::resolved(EditFileToolInput {
+ path: input_path,
+ edits: vec![Edit {
+ old_text: "old content".into(),
+ new_text: "new content".into(),
+ }],
+ }),
+ event_stream,
+ cx,
+ )
+ });
+
+ event_rx.expect_update_fields().await;
+ let auth = event_rx.expect_authorization().await;
+ let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
+ assert!(
+ title.contains("agent skills"),
+ "Authorization title should mention agent skills, got: {title}",
+ );
+ auth.response
+ .send(acp_thread::SelectedPermissionOutcome::new(
+ acp::PermissionOptionId::new("allow"),
+ acp::PermissionOptionKind::AllowOnce,
+ ))
+ .expect("authorization response should send");
+
+ let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
+ panic!("expected success");
+ };
+ assert_eq!(new_text, "new content\n");
+ assert_eq!(fs.load(&skill_file).await.unwrap(), "new content\n");
+ }
+
#[gpui::test]
async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) {
let (edit_tool, _project, _action_log, _fs, _thread) =
diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs
index 3eea6e57f3d..ed5112e908e 100644
--- a/crates/agent/src/tools/edit_session.rs
+++ b/crates/agent/src/tools/edit_session.rs
@@ -2,6 +2,7 @@ mod reindent;
mod streaming_fuzzy_matcher;
mod streaming_parser;
+use super::tool_permissions::resolve_creatable_global_skill_path;
use crate::{Thread, ToolCallEventStream};
use acp_thread::Diff;
use action_log::ActionLog;
@@ -363,6 +364,16 @@ pub(crate) struct EditSession {
_finalize_diff_guard: Deferred>,
}
+/// The destination of an edit session, identified by its absolute path on
+/// disk. `project_path` is `Some` for files that live inside one of the
+/// project's worktrees (i.e. that the standard project-path machinery can
+/// resolve), and `None` for global skill files reached through the
+/// `~/.agents/skills` allowlist.
+struct EditSessionTarget {
+ abs_path: PathBuf,
+ project_path: Option,
+}
+
enum Pipeline {
Write(WritePipeline),
Edit(EditPipeline),
@@ -650,16 +661,34 @@ impl EditSession {
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result {
- let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?;
+ let target = if let Some(abs_path) =
+ resolve_global_skill_path_for_edit_session(mode, &path, &context, cx).await?
+ {
+ EditSessionTarget {
+ abs_path,
+ project_path: None,
+ }
+ } else {
+ let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?;
- let Some(abs_path) =
- cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx))
- else {
- return Err(format!(
- "Worktree at '{}' does not exist",
- path.to_string_lossy()
- ));
+ let Some(abs_path) =
+ cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx))
+ else {
+ return Err(format!(
+ "Worktree at '{}' does not exist",
+ path.to_string_lossy()
+ ));
+ };
+
+ EditSessionTarget {
+ abs_path,
+ project_path: Some(project_path),
+ }
};
+ let EditSessionTarget {
+ abs_path,
+ project_path,
+ } = target;
event_stream.update_fields(
ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
@@ -669,11 +698,20 @@ impl EditSession {
.await
.map_err(|e| e.to_string())?;
- let buffer = context
- .project
- .update(cx, |project, cx| project.open_buffer(project_path, cx))
- .await
- .map_err(|e| e.to_string())?;
+ let buffer = match project_path {
+ Some(project_path) => context
+ .project
+ .update(cx, |project, cx| project.open_buffer(project_path, cx))
+ .await
+ .map_err(|e| e.to_string())?,
+ None => context
+ .project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(abs_path.clone(), cx)
+ })
+ .await
+ .map_err(|e| e.to_string())?,
+ };
let file_changed_since_last_read =
ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?;
@@ -1066,6 +1104,72 @@ async fn resolve_dirty_buffer(
Ok(())
}
+/// Mirrors [`resolve_path`]'s pre-auth validation for the global-skill
+/// branch: returns `Ok(Some(abs_path))` if the path lives under
+/// `~/.agents/skills` and is in a valid state for the requested mode,
+/// `Ok(None)` if the path isn't a global skill at all (so the caller should
+/// fall through to project-path resolution), or `Err(message)` if the path
+/// is a global skill but can't be used (missing in Edit mode, parent
+/// missing in Write mode, etc.).
+///
+/// Errors returned from here surface to the model as tool-result errors
+/// without prompting the user — same contract as [`resolve_path`]. The
+/// idea is that "file doesn't exist" or "parent isn't a directory" are
+/// model mistakes, not decisions the user should be asked to approve.
+async fn resolve_global_skill_path_for_edit_session(
+ mode: EditSessionMode,
+ path: &PathBuf,
+ context: &EditSessionContext,
+ cx: &mut AsyncApp,
+) -> Result