mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
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:
parent
b32570d931
commit
12aacf3cea
11 changed files with 328 additions and 37 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx);
|
||||
}
|
||||
|
||||
fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context<Self>) {
|
||||
let this = cx.weak_entity();
|
||||
let on_saved: Rc<dyn Fn(&mut App)> = Rc::new(move |cx: &mut App| {
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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<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 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<SettingsWindow>) -> 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)),
|
||||
|
|
|
|||
|
|
@ -772,6 +772,9 @@ pub struct SettingsWindow {
|
|||
pub(crate) hidden_deleted_skill_directory_paths: HashSet<PathBuf>,
|
||||
pub(crate) regex_validation_error: Option<String>,
|
||||
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 {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ pub enum SkillCreatorOpenMode {
|
|||
Url {
|
||||
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)]
|
||||
|
|
@ -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>,
|
||||
) {
|
||||
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>,
|
||||
) {
|
||||
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>) {
|
||||
// Surface any field-level errors before attempting to save.
|
||||
self.recompute_name_error(cx);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1157,6 +1157,28 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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::<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 } => {
|
||||
cx.perform_dock_menu_action(index);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=<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<()> {
|
||||
// Format: /?repo=<url> or ?repo=<url>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue