mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +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"
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue