Add skill share linking (#58009)

Added because I'd like to get this skill Danilo made without having to
upload a gist

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Mikayla Maki 2026-05-28 16:04:44 -07:00 committed by GitHub
parent b32570d931
commit 12aacf3cea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 328 additions and 37 deletions

3
Cargo.lock generated
View file

@ -426,6 +426,7 @@ name = "agent_skills"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"const_format", "const_format",
"fs", "fs",
"futures 0.3.32", "futures 0.3.32",
@ -434,6 +435,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng", "serde_yaml_ng",
"url",
"util", "util",
] ]
@ -23513,6 +23515,7 @@ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"agent_servers", "agent_servers",
"agent_settings", "agent_settings",
"agent_skills",
"agent_ui", "agent_ui",
"anyhow", "anyhow",
"ashpd", "ashpd",

View file

@ -13,6 +13,7 @@ path = "agent_skills.rs"
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
base64.workspace = true
const_format.workspace = true const_format.workspace = true
fs.workspace = true fs.workspace = true
futures.workspace = true futures.workspace = true
@ -20,6 +21,7 @@ gpui.workspace = true
paths.workspace = true paths.workspace = true
serde.workspace = true serde.workspace = true
serde_yaml_ng.workspace = true serde_yaml_ng.workspace = true
url.workspace = true
util.workspace = true util.workspace = true
[dev-dependencies] [dev-dependencies]

View file

@ -6,6 +6,7 @@ use gpui::{Global, SharedString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use url::Url;
use util::paths::component_matches_ignore_ascii_case; use util::paths::component_matches_ignore_ascii_case;
/// First segment of the skills directory path: `.agents`. /// First segment of the skills directory path: `.agents`.
@ -731,6 +732,58 @@ pub fn is_agents_skills_path(path: &Path) -> bool {
false false
} }
/// The `zed://` scheme used by share links.
const SKILL_SHARE_LINK_SCHEME: &str = "zed";
/// The host (the part after `zed://`) that identifies a skill share link.
const SKILL_SHARE_LINK_HOST: &str = "skill";
/// The query parameter that carries the embedded `SKILL.md` payload.
const SKILL_SHARE_LINK_DATA_PARAM: &str = "data";
/// The `zed://` deep-link prefix for a shared skill. Opening a link with this
/// prefix prompts the recipient to review and install the embedded skill.
pub const SKILL_SHARE_LINK_PREFIX: &str =
concatcp!(SKILL_SHARE_LINK_SCHEME, "://", SKILL_SHARE_LINK_HOST);
/// Build a shareable `zed://skill?data=…` link that fully embeds the given
/// `SKILL.md` file contents.
///
/// The contents are base64url-encoded (no padding) so the link is
/// self-contained and URL-safe: the recipient doesn't need the skill to be
/// hosted anywhere. Recover the contents with [`decode_skill_share_link`].
pub fn encode_skill_share_link(skill_file_content: &str) -> String {
use base64::Engine as _;
let data =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(skill_file_content.as_bytes());
let mut url = Url::parse(SKILL_SHARE_LINK_PREFIX).expect("skill share link prefix is valid");
url.query_pairs_mut()
.append_pair(SKILL_SHARE_LINK_DATA_PARAM, &data);
url.into()
}
/// Recover the `SKILL.md` contents embedded in a `zed://skill?data=…` link
/// produced by [`encode_skill_share_link`].
pub fn decode_skill_share_link(link: &str) -> Result<String> {
use base64::Engine as _;
let url = Url::parse(link).context("skill share link is not a valid URL")?;
anyhow::ensure!(
url.scheme() == SKILL_SHARE_LINK_SCHEME && url.host_str() == Some(SKILL_SHARE_LINK_HOST),
"not a skill share link"
);
let data = url
.query_pairs()
.find_map(|(key, value)| (key == SKILL_SHARE_LINK_DATA_PARAM).then_some(value))
.context("skill share link is missing the `data` parameter")?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(data.as_bytes())
.context("skill share link `data` is not valid base64")?;
anyhow::ensure!(
bytes.len() <= MAX_SKILL_FILE_SIZE,
"shared skill exceeds the maximum size of {MAX_SKILL_FILE_SIZE} bytes"
);
let content = String::from_utf8(bytes).context("skill share link `data` is not valid UTF-8")?;
Ok(content)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1959,4 +2012,25 @@ description: A skill with no body content
} }
} }
} }
#[test]
fn skill_share_link_round_trips() {
let content =
"---\nname: my-skill\ndescription: Does a thing.\n---\n\n## Steps\n\nDo the thing.\n";
let link = encode_skill_share_link(content);
let data = link
.strip_prefix("zed://skill?data=")
.expect("link should start with the skill share prefix");
// base64url (no-pad) output must not require percent-encoding.
assert!(!data.contains('+') && !data.contains('/') && !data.contains('='));
assert_eq!(decode_skill_share_link(&link).unwrap(), content);
}
#[test]
fn decode_skill_share_link_rejects_non_skill_links() {
assert!(decode_skill_share_link("zed://settings/agent.skills").is_err());
assert!(decode_skill_share_link("zed://skill").is_err());
assert!(decode_skill_share_link("zed://skill?other=1").is_err());
assert!(decode_skill_share_link("zed://skill?data=!!!notbase64").is_err());
}
} }

View file

@ -3251,6 +3251,13 @@ impl AgentPanel {
self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx); self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx);
} }
/// Open the skill creator pre-filled with a skill received from a
/// `zed://skill` share link, so the user can review it and choose a scope
/// before installing.
pub fn install_shared_skill(&mut self, content: String, cx: &mut Context<Self>) {
self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx);
}
fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context<Self>) { fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context<Self>) {
let this = cx.weak_entity(); let this = cx.weak_entity();
let on_saved: Rc<dyn Fn(&mut App)> = Rc::new(move |cx: &mut App| { let on_saved: Rc<dyn Fn(&mut App)> = Rc::new(move |cx: &mut App| {

View file

@ -1,6 +1,6 @@
use agent_skills::{Skill, SkillIndex}; use agent_skills::{Skill, SkillIndex, encode_skill_share_link};
use fs::RemoveOptions; use fs::RemoveOptions;
use gpui::{Action as _, ScrollHandle, SharedString, prelude::*}; use gpui::{Action as _, ClipboardItem, ScrollHandle, SharedString, prelude::*};
use ui::{Divider, Tooltip, prelude::*}; use ui::{Divider, Tooltip, prelude::*};
use util::ResultExt as _; use util::ResultExt as _;
@ -93,7 +93,8 @@ pub(crate) fn render_skills_setup_page(
this.track_scroll(scroll_handle) this.track_scroll(scroll_handle)
.overflow_y_scroll() .overflow_y_scroll()
.children(skills.iter().enumerate().flat_map(|(i, skill)| { .children(skills.iter().enumerate().flat_map(|(i, skill)| {
let mut elements: Vec<AnyElement> = vec![render_skill_row(skill, cx)]; let mut elements: Vec<AnyElement> =
vec![render_skill_row(skill, settings_window, cx)];
if i + 1 < skills.len() { if i + 1 < skills.len() {
elements.push(Divider::horizontal().into_any_element()); elements.push(Divider::horizontal().into_any_element());
} }
@ -104,10 +105,22 @@ pub(crate) fn render_skills_setup_page(
.into_any_element() .into_any_element()
} }
fn render_skill_row(skill: &Skill, cx: &mut Context<SettingsWindow>) -> AnyElement { fn render_skill_row(
skill: &Skill,
settings_window: &SettingsWindow,
cx: &mut Context<SettingsWindow>,
) -> AnyElement {
let skill_file_path = skill.skill_file_path.clone(); let skill_file_path = skill.skill_file_path.clone();
let directory_path = skill.directory_path.clone(); let directory_path = skill.directory_path.clone();
let share_copied = settings_window.last_copied_skill_directory_path.as_deref()
== Some(skill.directory_path.as_path());
let (share_icon, share_icon_color) = if share_copied {
(IconName::Check, Color::Success)
} else {
(IconName::Link, Color::Muted)
};
h_flex() h_flex()
.w_full() .w_full()
.justify_between() .justify_between()
@ -128,6 +141,50 @@ fn render_skill_row(skill: &Skill, cx: &mut Context<SettingsWindow>) -> AnyEleme
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.child({
let share_skill_file_path = skill.skill_file_path.clone();
let share_directory_path = skill.directory_path.clone();
IconButton::new(
SharedString::from(format!("share-{}", skill.name)),
share_icon,
)
.tab_index(0_isize)
.icon_size(IconSize::Small)
.icon_color(share_icon_color)
.tooltip(Tooltip::text("Copy Share Link"))
.on_click(cx.listener(
move |_settings_window, _event, _window, cx| {
let skill_file_path = share_skill_file_path.clone();
let directory_path = share_directory_path.clone();
let app_state = workspace::AppState::global(cx);
let fs = app_state.fs.clone();
cx.spawn(async move |settings_window, cx| {
match fs.load(&skill_file_path).await {
Ok(content) => {
let link = encode_skill_share_link(&content);
settings_window
.update(cx, |settings_window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
link,
));
settings_window.last_copied_skill_directory_path =
Some(directory_path.clone());
cx.notify();
})
.ok();
}
Err(error) => {
log::error!(
"failed to read skill file {} for sharing: {error:#}",
skill_file_path.display()
);
}
}
})
.detach();
},
))
})
.child( .child(
IconButton::new( IconButton::new(
SharedString::from(format!("delete-{}", skill.name)), SharedString::from(format!("delete-{}", skill.name)),

View file

@ -772,6 +772,9 @@ pub struct SettingsWindow {
pub(crate) hidden_deleted_skill_directory_paths: HashSet<PathBuf>, pub(crate) hidden_deleted_skill_directory_paths: HashSet<PathBuf>,
pub(crate) regex_validation_error: Option<String>, pub(crate) regex_validation_error: Option<String>,
last_copied_link_path: Option<&'static str>, last_copied_link_path: Option<&'static str>,
/// Directory path of the skill whose share link was most recently copied,
/// used to show a transient "copied" checkmark on its share button.
pub(crate) last_copied_skill_directory_path: Option<PathBuf>,
} }
struct SearchDocument { struct SearchDocument {
@ -1718,6 +1721,7 @@ impl SettingsWindow {
regex_validation_error: None, regex_validation_error: None,
list_state, list_state,
last_copied_link_path: None, last_copied_link_path: None,
last_copied_skill_directory_path: None,
}; };
this.fetch_files(window, cx); this.fetch_files(window, cx);
@ -2323,6 +2327,10 @@ impl SettingsWindow {
} }
fn open_navbar_entry_page(&mut self, navbar_entry: usize) { fn open_navbar_entry_page(&mut self, navbar_entry: usize) {
// Navigating to another page dismisses the transient "copied share
// link" checkmark shown on a Skills page row.
self.last_copied_skill_directory_path = None;
if !self.is_nav_entry_visible(navbar_entry) { if !self.is_nav_entry_visible(navbar_entry) {
self.open_first_nav_page(); self.open_first_nav_page();
} }
@ -4577,6 +4585,7 @@ pub mod test {
hidden_deleted_skill_directory_paths: HashSet::default(), hidden_deleted_skill_directory_paths: HashSet::default(),
regex_validation_error: None, regex_validation_error: None,
last_copied_link_path: None, last_copied_link_path: None,
last_copied_skill_directory_path: None,
} }
} }
} }
@ -4704,6 +4713,7 @@ pub mod test {
hidden_deleted_skill_directory_paths: HashSet::default(), hidden_deleted_skill_directory_paths: HashSet::default(),
regex_validation_error: None, regex_validation_error: None,
last_copied_link_path: None, last_copied_link_path: None,
last_copied_skill_directory_path: None,
}; };
settings_window.build_filter_table(); settings_window.build_filter_table();

View file

@ -56,6 +56,13 @@ pub enum SkillCreatorOpenMode {
Url { Url {
initial_url: Option<String>, initial_url: Option<String>,
}, },
/// Review and install a skill whose full `SKILL.md` contents are
/// supplied inline, e.g. from a `zed://skill` share link. The form is
/// pre-filled with the parsed skill so the recipient can review it and
/// pick a scope before saving.
Install {
content: String,
},
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -557,6 +564,33 @@ impl SkillCreator {
SkillCreatorOpenMode::Url { initial_url } => { SkillCreatorOpenMode::Url { initial_url } => {
self.open_url_import(initial_url, window, cx); self.open_url_import(initial_url, window, cx);
} }
SkillCreatorOpenMode::Install { content } => {
self.open_install_review(content, window, cx);
}
}
}
/// Pre-fill the form with a skill supplied inline (from a share link) so
/// the recipient can review it before saving. Unlike URL import, this
/// doesn't touch the URL editor or perform any network request.
fn open_install_review(
&mut self,
content: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.url_import_debounce_task = None;
self.url_import_task = None;
self.url_import_status = UrlImportStatus::Idle;
match parse_imported_skill(&content, "") {
Ok(imported) => self.apply_imported_skill(imported, window, cx),
Err(err) => {
self.save_error = Some(SharedString::from(format!(
"Couldn't read shared skill: {err}"
)));
cx.notify();
}
} }
} }
@ -651,44 +685,12 @@ impl SkillCreator {
let fetch_task = cx.background_spawn(fetch_imported_skill_from_url(http_client, url)); let fetch_task = cx.background_spawn(fetch_imported_skill_from_url(http_client, url));
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
let result = fetch_task.await; let result = fetch_task.await;
let skill_creator = this.clone();
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.url_import_debounce_task = None; this.url_import_debounce_task = None;
this.url_import_task = None; this.url_import_task = None;
match result { match result {
Ok(imported) => { Ok(imported) => {
this.url_import_status = UrlImportStatus::Idle; this.apply_imported_skill(imported, window, cx);
this.save_error = None;
let name_editor = this.name_editor.clone();
let description_editor = this.description_editor.clone();
let body_editor = this.body_editor.clone();
window.defer(cx, move |window, cx| {
name_editor.update(cx, |input, cx| {
input.set_text(&imported.name, window, cx);
});
description_editor.update(cx, |input, cx| {
input.set_text(&imported.description, window, cx);
});
body_editor.update(cx, |editor, cx| {
editor.set_text(imported.body.clone(), window, cx);
});
skill_creator
.update(cx, |this, cx| {
this.disable_model_invocation =
imported.disable_model_invocation;
this.url_import_status = UrlImportStatus::Idle;
this.url_import_debounce_task = None;
this.url_import_task = None;
this.save_error = None;
this.recompute_name_error(cx);
this.recompute_description_error(cx);
this.recompute_body_error(cx);
cx.notify();
})
.log_err();
window.focus(&name_editor.focus_handle(cx), cx);
});
} }
Err(err) => { Err(err) => {
this.url_import_status = this.url_import_status =
@ -703,6 +705,49 @@ impl SkillCreator {
cx.notify(); cx.notify();
} }
/// Populate the form fields from a parsed skill (shared by URL import and
/// share-link install). Deferred so the programmatic `set_text` calls run
/// before focus moves to the name field.
fn apply_imported_skill(
&mut self,
imported: ImportedSkill,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.url_import_status = UrlImportStatus::Idle;
self.save_error = None;
let name_editor = self.name_editor.clone();
let description_editor = self.description_editor.clone();
let body_editor = self.body_editor.clone();
let skill_creator = cx.weak_entity();
window.defer(cx, move |window, cx| {
name_editor.update(cx, |input, cx| {
input.set_text(&imported.name, window, cx);
});
description_editor.update(cx, |input, cx| {
input.set_text(&imported.description, window, cx);
});
body_editor.update(cx, |editor, cx| {
editor.set_text(imported.body.clone(), window, cx);
});
skill_creator
.update(cx, |this, cx| {
this.disable_model_invocation = imported.disable_model_invocation;
this.url_import_status = UrlImportStatus::Idle;
this.url_import_debounce_task = None;
this.url_import_task = None;
this.save_error = None;
this.recompute_name_error(cx);
this.recompute_description_error(cx);
this.recompute_body_error(cx);
cx.notify();
})
.log_err();
window.focus(&name_editor.focus_handle(cx), cx);
});
}
fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context<Self>) { fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context<Self>) {
// Surface any field-level errors before attempting to save. // Surface any field-level errors before attempting to save.
self.recompute_name_error(cx); self.recompute_name_error(cx);

View file

@ -68,6 +68,7 @@ activity_indicator.workspace = true
agent.workspace = true agent.workspace = true
agent-client-protocol.workspace = true agent-client-protocol.workspace = true
agent_settings.workspace = true agent_settings.workspace = true
agent_skills.workspace = true
agent_ui = { workspace = true, features = ["audio"] } agent_ui = { workspace = true, features = ["audio"] }
anyhow.workspace = true anyhow.workspace = true
askpass.workspace = true askpass.workspace = true

View file

@ -1157,6 +1157,28 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
OpenRequestKind::InstallSkill { content } => {
cx.spawn(async move |cx| {
let multi_workspace =
workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
multi_workspace.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
panel.update(cx, |panel, cx| {
panel.install_shared_skill(content, cx);
});
} else {
log::warn!(
"zed://skill received but the AgentPanel is not registered \
(is `disable_ai` enabled?)"
);
}
});
})
})
.detach_and_log_err(cx);
}
OpenRequestKind::DockMenuAction { index } => { OpenRequestKind::DockMenuAction { index } => {
cx.perform_dock_menu_action(index); cx.perform_dock_menu_action(index);
} }

View file

@ -62,6 +62,10 @@ pub enum OpenRequestKind {
SharedAgentThread { SharedAgentThread {
session_id: String, session_id: String,
}, },
InstallSkill {
/// Full `SKILL.md` contents embedded in a `zed://skill` share link.
content: String,
},
DockMenuAction { DockMenuAction {
index: usize, index: usize,
}, },
@ -99,6 +103,10 @@ impl std::fmt::Debug for OpenRequestKind {
.debug_struct("SharedAgentThread") .debug_struct("SharedAgentThread")
.field("session_id", session_id) .field("session_id", session_id)
.finish(), .finish(),
Self::InstallSkill { content } => f
.debug_struct("InstallSkill")
.field("content_len", &content.len())
.finish(),
Self::DockMenuAction { index } => f Self::DockMenuAction { index } => f
.debug_struct("DockMenuAction") .debug_struct("DockMenuAction")
.field("index", index) .field("index", index)
@ -178,6 +186,8 @@ impl OpenRequest {
} else { } else {
log::error!("Invalid session ID in URL: {}", session_id_str); log::error!("Invalid session ID in URL: {}", session_id_str);
} }
} else if url.starts_with(agent_skills::SKILL_SHARE_LINK_PREFIX) {
this.parse_skill_install_url(&url)?
} else if let Some(agent_path) = url.strip_prefix("zed://agent") { } else if let Some(agent_path) = url.strip_prefix("zed://agent") {
this.parse_agent_url(agent_path) this.parse_agent_url(agent_path)
} else if url == "zed://" || url == "zed://open" || url == "zed://open/" { } else if url == "zed://" || url == "zed://open" || url == "zed://open/" {
@ -237,6 +247,13 @@ impl OpenRequest {
}); });
} }
fn parse_skill_install_url(&mut self, url: &str) -> Result<()> {
// Format: zed://skill?data=<base64url of SKILL.md contents>
let content = agent_skills::decode_skill_share_link(url)?;
self.kind = Some(OpenRequestKind::InstallSkill { content });
Ok(())
}
fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> { fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
// Format: /?repo=<url> or ?repo=<url> // Format: /?repo=<url> or ?repo=<url>
let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path); let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
@ -1268,6 +1285,52 @@ mod tests {
} }
} }
#[gpui::test]
fn test_parse_skill_install_url(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let content =
"---\nname: my-skill\ndescription: Does a thing.\n---\n\nDo the thing.\n".to_string();
let link = agent_skills::encode_skill_share_link(&content);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![link],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::InstallSkill {
content: parsed_content,
}) => {
assert_eq!(parsed_content, content);
}
_ => panic!("Expected InstallSkill kind"),
}
}
#[gpui::test]
fn test_parse_malformed_skill_install_url_errors(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let result = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec!["zed://skill?data=!!!notbase64".into()],
..Default::default()
},
cx,
)
});
assert!(result.is_err());
}
fn agent_url_with_prompt(prompt: &str) -> String { fn agent_url_with_prompt(prompt: &str) -> String {
let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string()); let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string());
serializer.append_pair("prompt", prompt); serializer.append_pair("prompt", prompt);

View file

@ -39,11 +39,18 @@ The **User** tab shows your global skills. The **Project** tab shows skills for
For each skill you can: For each skill you can:
- **Copy Share Link** — copies a `zed://skill` link that embeds the skill, ready to send to someone else (see [Sharing Skills](#sharing-skills))
- **Open** — opens the skill's `SKILL.md` file in the editor - **Open** — opens the skill's `SKILL.md` file in the editor
- **Delete** — removes the skill folder from disk - **Delete** — removes the skill folder from disk
If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator.
## Sharing Skills {#sharing-skills}
You can hand a skill to a teammate without hosting it anywhere. In the Skills settings page, click the **link** icon on a skill row to copy a `zed://skill?data=…` link to your clipboard. The link is self-contained: it embeds the full `SKILL.md` contents (base64url-encoded), so the recipient doesn't need access to your project or any registry.
When someone opens that link (for example by pasting it into their browser or clicking it in a chat), Zed launches the Skill Creator pre-filled with the shared skill. The recipient can review the name, description, and full body, choose a scope (global or project-local), and click **Save** to install it. Nothing is written to disk until they explicitly save, so a shared link can never silently install instructions into someone's agent.
## Managing Skills {#managing-skills} ## Managing Skills {#managing-skills}
Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills).