diff --git a/Cargo.lock b/Cargo.lock index 5f11b1956c8..97bafe5737e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ name = "agent_skills" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "const_format", "fs", "futures 0.3.32", @@ -434,6 +435,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "url", "util", ] @@ -23513,6 +23515,7 @@ dependencies = [ "agent-client-protocol", "agent_servers", "agent_settings", + "agent_skills", "agent_ui", "anyhow", "ashpd", diff --git a/crates/agent_skills/Cargo.toml b/crates/agent_skills/Cargo.toml index db3fb7e2947..31864f7a4f0 100644 --- a/crates/agent_skills/Cargo.toml +++ b/crates/agent_skills/Cargo.toml @@ -13,6 +13,7 @@ path = "agent_skills.rs" [dependencies] anyhow.workspace = true +base64.workspace = true const_format.workspace = true fs.workspace = true futures.workspace = true @@ -20,6 +21,7 @@ gpui.workspace = true paths.workspace = true serde.workspace = true serde_yaml_ng.workspace = true +url.workspace = true util.workspace = true [dev-dependencies] diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 7f0d863fc91..e2ffc550be3 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -6,6 +6,7 @@ use gpui::{Global, SharedString}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use url::Url; use util::paths::component_matches_ignore_ascii_case; /// First segment of the skills directory path: `.agents`. @@ -731,6 +732,58 @@ pub fn is_agents_skills_path(path: &Path) -> bool { 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 { + 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)] mod tests { 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()); + } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 540abc040f2..72ba53045e5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3251,6 +3251,13 @@ impl AgentPanel { 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.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx); + } + fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context) { let this = cx.weak_entity(); let on_saved: Rc = Rc::new(move |cx: &mut App| { diff --git a/crates/settings_ui/src/pages/skills_setup.rs b/crates/settings_ui/src/pages/skills_setup.rs index 0208f7db47f..8f9c7b16f30 100644 --- a/crates/settings_ui/src/pages/skills_setup.rs +++ b/crates/settings_ui/src/pages/skills_setup.rs @@ -1,6 +1,6 @@ -use agent_skills::{Skill, SkillIndex}; +use agent_skills::{Skill, SkillIndex, encode_skill_share_link}; use fs::RemoveOptions; -use gpui::{Action as _, ScrollHandle, SharedString, prelude::*}; +use gpui::{Action as _, ClipboardItem, ScrollHandle, SharedString, prelude::*}; use ui::{Divider, Tooltip, prelude::*}; use util::ResultExt as _; @@ -93,7 +93,8 @@ pub(crate) fn render_skills_setup_page( this.track_scroll(scroll_handle) .overflow_y_scroll() .children(skills.iter().enumerate().flat_map(|(i, skill)| { - let mut elements: Vec = vec![render_skill_row(skill, cx)]; + let mut elements: Vec = + vec![render_skill_row(skill, settings_window, cx)]; if i + 1 < skills.len() { elements.push(Divider::horizontal().into_any_element()); } @@ -104,10 +105,22 @@ pub(crate) fn render_skills_setup_page( .into_any_element() } -fn render_skill_row(skill: &Skill, cx: &mut Context) -> AnyElement { +fn render_skill_row( + skill: &Skill, + settings_window: &SettingsWindow, + cx: &mut Context, +) -> AnyElement { let skill_file_path = skill.skill_file_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() .w_full() .justify_between() @@ -128,6 +141,50 @@ fn render_skill_row(skill: &Skill, cx: &mut Context) -> AnyEleme .child( h_flex() .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( IconButton::new( SharedString::from(format!("delete-{}", skill.name)), diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 2a49a95af2a..eae0e60166e 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -772,6 +772,9 @@ pub struct SettingsWindow { pub(crate) hidden_deleted_skill_directory_paths: HashSet, pub(crate) regex_validation_error: Option, 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, } struct SearchDocument { @@ -1718,6 +1721,7 @@ impl SettingsWindow { regex_validation_error: None, list_state, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; this.fetch_files(window, cx); @@ -2323,6 +2327,10 @@ impl SettingsWindow { } 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) { self.open_first_nav_page(); } @@ -4577,6 +4585,7 @@ pub mod test { hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: 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(), regex_validation_error: None, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; settings_window.build_filter_table(); diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index 3ed3ee49b33..ffbe28801ad 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -56,6 +56,13 @@ pub enum SkillCreatorOpenMode { Url { initial_url: Option, }, + /// 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)] @@ -557,6 +564,33 @@ impl SkillCreator { SkillCreatorOpenMode::Url { initial_url } => { 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.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 task = cx.spawn_in(window, async move |this, cx| { let result = fetch_task.await; - let skill_creator = this.clone(); this.update_in(cx, |this, window, cx| { this.url_import_debounce_task = None; this.url_import_task = None; match result { Ok(imported) => { - this.url_import_status = UrlImportStatus::Idle; - 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); - }); + this.apply_imported_skill(imported, window, cx); } Err(err) => { this.url_import_status = @@ -703,6 +705,49 @@ impl SkillCreator { 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.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) { // Surface any field-level errors before attempting to save. self.recompute_name_error(cx); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 7d0e9b46364..691f06719bb 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -68,6 +68,7 @@ activity_indicator.workspace = true agent.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true +agent_skills.workspace = true agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true askpass.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index b2a738f9314..6a832da767c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1157,6 +1157,28 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .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::(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 } => { cx.perform_dock_menu_action(index); } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 4a4f5fca518..a68d827373e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -62,6 +62,10 @@ pub enum OpenRequestKind { SharedAgentThread { session_id: String, }, + InstallSkill { + /// Full `SKILL.md` contents embedded in a `zed://skill` share link. + content: String, + }, DockMenuAction { index: usize, }, @@ -99,6 +103,10 @@ impl std::fmt::Debug for OpenRequestKind { .debug_struct("SharedAgentThread") .field("session_id", session_id) .finish(), + Self::InstallSkill { content } => f + .debug_struct("InstallSkill") + .field("content_len", &content.len()) + .finish(), Self::DockMenuAction { index } => f .debug_struct("DockMenuAction") .field("index", index) @@ -178,6 +186,8 @@ impl OpenRequest { } else { 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") { this.parse_agent_url(agent_path) } 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= + 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<()> { // Format: /?repo= or ?repo= 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 { let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string()); serializer.append_pair("prompt", prompt); diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md index 997e4e56f9f..39ee6844d24 100644 --- a/docs/src/ai/skills.md +++ b/docs/src/ai/skills.md @@ -39,11 +39,18 @@ The **User** tab shows your global skills. The **Project** tab shows skills for 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 - **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. +## 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} 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).