Skip to repository content1768 lines · 61.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:43:11.548Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
skill_creator.rs
1use agent_skills::{
2 AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTION_LEN, MAX_SKILL_FILE_SIZE, SKILL_FILE_NAME,
3 SKILLS_DIR_NAME, SkillMetadata, SkillsUpdatedHook, global_skills_dir, parse_skill_file_content,
4 slugify_skill_name, validate_description, validate_name,
5};
6use anyhow::{Context as _, Result, anyhow};
7use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
8use fs::Fs;
9use futures::AsyncReadExt;
10use gpui::{
11 App, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Subscription, Task, TextStyle,
12 WeakEntity, WindowHandle, actions,
13};
14use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request, StatusCode, Url};
15use language::{Buffer, language_settings::SoftWrap};
16use settings::{ActionSequence, Settings};
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::Duration;
20use theme_settings::ThemeSettings;
21use ui::{Banner, Divider, SwitchField, WithScrollbar, prelude::*};
22use ui_input::{ErasedEditorEvent, InputField};
23use util::ResultExt;
24use workspace::MultiWorkspace;
25
26use crate::{SettingsUiFile, SettingsWindow, all_projects};
27
28actions!(
29 skill_creator,
30 [SaveSkill, Cancel, FocusNextField, FocusPreviousField,]
31);
32
33const URL_FIELD_TAB_INDEX: isize = 1;
34const NAME_FIELD_TAB_INDEX: isize = 2;
35const DESCRIPTION_FIELD_TAB_INDEX: isize = 3;
36const DISABLE_MODEL_INVOCATION_TAB_INDEX: isize = 4;
37const BODY_FIELD_TAB_INDEX: isize = 5;
38const SAVE_BUTTON_TAB_INDEX: isize = 6;
39const URL_IMPORT_DEBOUNCE: Duration = Duration::from_millis(300);
40const URL_IMPORT_ERROR_BODY_MAX_LEN: usize = 2048;
41
42#[derive(Clone, Debug, Default)]
43pub enum SkillCreatorOpenMode {
44 #[default]
45 Form,
46 Url {
47 initial_url: Option<String>,
48 },
49 Install {
50 content: String,
51 },
52}
53
54pub(crate) enum SkillCreatorEvent {
55 Dismissed,
56 Saved,
57}
58
59#[derive(Clone, Debug)]
60enum UrlImportStatus {
61 Idle,
62 Fetching,
63 Error(SharedString),
64}
65
66#[derive(Debug)]
67struct ImportedSkill {
68 name: String,
69 description: String,
70 body: String,
71 disable_model_invocation: bool,
72}
73
74#[derive(Clone, Debug, PartialEq)]
75enum ScopeChoice {
76 Global,
77 Project {
78 root_name: SharedString,
79 abs_path: Arc<std::path::Path>,
80 },
81}
82
83impl ScopeChoice {
84 /// Absolute path of the `.agents/skills` directory this scope writes to.
85 fn skills_dir(&self) -> PathBuf {
86 match self {
87 ScopeChoice::Global => global_skills_dir(),
88 ScopeChoice::Project { abs_path, .. } => {
89 abs_path.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME)
90 }
91 }
92 }
93}
94
95fn scope_for_settings_file(
96 current_file: &SettingsUiFile,
97 original_window: Option<&WindowHandle<MultiWorkspace>>,
98 cx: &App,
99) -> ScopeChoice {
100 if let SettingsUiFile::Project((worktree_id, _)) = current_file {
101 for project in all_projects(original_window, cx) {
102 if let Some(worktree) = project.read(cx).worktree_for_id(*worktree_id, cx) {
103 let worktree = worktree.read(cx);
104 return ScopeChoice::Project {
105 root_name: SharedString::from(worktree.root_name_str().to_string()),
106 abs_path: worktree.abs_path(),
107 };
108 }
109 }
110 }
111 ScopeChoice::Global
112}
113
114pub(crate) fn skill_url_from_clipboard(cx: &App) -> Option<String> {
115 cx.read_from_clipboard()
116 .and_then(|clipboard| clipboard.text())
117 .map(|text| text.trim().to_string())
118 .filter(|text| is_supported_skill_url(text))
119}
120
121/// Renders the skill creator sub-page pushed by
122/// [`SettingsWindow::open_skill_creator_sub_page`].
123pub(crate) fn render_skill_creator_page(
124 settings_window: &SettingsWindow,
125 _scroll_handle: &ScrollHandle,
126 _window: &mut Window,
127 _cx: &mut Context<SettingsWindow>,
128) -> AnyElement {
129 let Some(page) = settings_window.skill_creator_page() else {
130 return gpui::Empty.into_any_element();
131 };
132 page.into_any_element()
133}
134
135pub struct SkillCreatorPage {
136 focus_handle: FocusHandle,
137 fs: Arc<dyn Fs>,
138 http_client: Arc<dyn HttpClient>,
139 url_editor: Entity<InputField>,
140 name_editor: Entity<InputField>,
141 description_editor: Entity<InputField>,
142 body_editor: Entity<Editor>,
143 description_length: usize,
144 settings_window: WeakEntity<SettingsWindow>,
145 disable_model_invocation: bool,
146 name_error: Option<&'static str>,
147 description_error: Option<&'static str>,
148 body_error: Option<&'static str>,
149 save_error: Option<SharedString>,
150 url_import_status: UrlImportStatus,
151 saving: bool,
152 save_task: Option<Task<()>>,
153 url_import_debounce_task: Option<Task<()>>,
154 url_import_task: Option<Task<()>>,
155 scroll_handle: ScrollHandle,
156 _subscriptions: Vec<Subscription>,
157}
158
159impl EventEmitter<SkillCreatorEvent> for SkillCreatorPage {}
160
161impl SkillCreatorPage {
162 pub(crate) fn new(
163 settings_window: WeakEntity<SettingsWindow>,
164 window: &mut Window,
165 cx: &mut Context<Self>,
166 ) -> Self {
167 let app_state = workspace::AppState::global(cx);
168 let fs = app_state.fs.clone();
169 let language_registry = app_state.languages.clone();
170 let http_client = cx.http_client();
171
172 let focus_handle = cx.focus_handle();
173
174 let url_editor = cx.new(|cx| {
175 InputField::new(
176 window,
177 cx,
178 "https://github.com/owner/repo/blob/main/path/to/SKILL.md",
179 )
180 .tab_index(URL_FIELD_TAB_INDEX)
181 .tab_stop(true)
182 });
183
184 let name_editor = cx.new(|cx| {
185 InputField::new(window, cx, "my-new-skill")
186 .label("Name")
187 .tab_index(NAME_FIELD_TAB_INDEX)
188 .tab_stop(true)
189 });
190 // Focus the name field on open.
191 window.focus(&name_editor.focus_handle(cx), cx);
192
193 let description_editor = cx.new(|cx| {
194 InputField::new(
195 window,
196 cx,
197 "e.g., Fill the PR description following this template.",
198 )
199 .label("Description")
200 .tab_index(DESCRIPTION_FIELD_TAB_INDEX)
201 .tab_stop(true)
202 });
203
204 let body_editor = cx.new(|cx| {
205 let buffer = cx.new(|cx| {
206 let buffer = Buffer::local(String::new(), cx);
207 buffer.set_language_registry(language_registry.clone());
208 buffer
209 });
210 let mut editor = Editor::for_buffer(buffer, None, window, cx);
211 editor.set_placeholder_text("Add skill content…", window, cx);
212 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
213 editor.set_show_gutter(false, cx);
214 editor.set_show_wrap_guides(false, cx);
215 editor.set_show_indent_guides(false, cx);
216 editor.set_use_modal_editing(true);
217 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
218 editor
219 });
220
221 cx.spawn_in(window, {
222 let body_editor = body_editor.downgrade();
223 let language_registry = language_registry.clone();
224 async move |_this, cx| {
225 let markdown = language_registry.language_for_name("Markdown").await.ok();
226 if let Some(markdown) = markdown {
227 body_editor
228 .update(cx, |editor, cx| {
229 editor.buffer().update(cx, |multi_buffer, cx| {
230 if let Some(buffer) = multi_buffer.as_singleton() {
231 buffer.update(cx, |buffer, cx| {
232 buffer.set_language(Some(markdown), cx)
233 });
234 }
235 });
236 })
237 .ok();
238 }
239 }
240 })
241 .detach();
242
243 let url_input_editor = url_editor.read(cx).editor().clone();
244 let name_input_editor = name_editor.read(cx).editor().clone();
245 let description_input_editor = description_editor.read(cx).editor().clone();
246 let weak = cx.weak_entity();
247 let url_subscription = url_input_editor.subscribe(
248 Box::new(move |event, window, cx| {
249 weak.update(cx, |this, cx| {
250 this.handle_url_input_event(&event, window, cx);
251 })
252 .ok();
253 }),
254 window,
255 cx,
256 );
257 let weak = cx.weak_entity();
258 let name_subscription = name_input_editor.subscribe(
259 Box::new(move |event, window, cx| {
260 weak.update(cx, |this, cx| {
261 this.handle_name_input_event(&event, window, cx);
262 })
263 .ok();
264 }),
265 window,
266 cx,
267 );
268 let weak = cx.weak_entity();
269 let description_subscription = description_input_editor.subscribe(
270 Box::new(move |event, window, cx| {
271 weak.update(cx, |this, cx| {
272 this.handle_description_input_event(&event, window, cx);
273 })
274 .ok();
275 }),
276 window,
277 cx,
278 );
279
280 let subscriptions = vec![
281 url_subscription,
282 name_subscription,
283 description_subscription,
284 cx.subscribe_in(&body_editor, window, Self::handle_body_editor_event),
285 ];
286
287 Self {
288 focus_handle,
289 fs,
290 http_client,
291 url_editor,
292 name_editor,
293 description_editor,
294 body_editor,
295 description_length: 0,
296 settings_window,
297 disable_model_invocation: false,
298 name_error: None,
299 description_error: None,
300 body_error: None,
301 save_error: None,
302 url_import_status: UrlImportStatus::Idle,
303 saving: false,
304 save_task: None,
305 url_import_debounce_task: None,
306 url_import_task: None,
307 scroll_handle: ScrollHandle::new(),
308 _subscriptions: subscriptions,
309 }
310 }
311
312 pub(crate) fn name_editor_focus_handle(&self, cx: &App) -> FocusHandle {
313 self.name_editor.focus_handle(cx)
314 }
315
316 fn handle_url_input_event(
317 &mut self,
318 event: &ErasedEditorEvent,
319 window: &mut Window,
320 cx: &mut Context<Self>,
321 ) {
322 if !matches!(event, ErasedEditorEvent::BufferEdited) {
323 return;
324 }
325
326 // Convention from `thread_view::handle_title_editor_event` and
327 // `agent_panel::handle_terminal_title_editor_event`: programmatic
328 // `set_text` is performed while the editor is unfocused, so the
329 // focus check filters synthesized `BufferEdited` events out of
330 // the user-edit path without needing a one-shot suppression flag.
331 if !self.url_editor.focus_handle(cx).is_focused(window) {
332 return;
333 }
334
335 self.save_error = None;
336 self.schedule_url_import(window, cx);
337 }
338
339 fn handle_name_input_event(
340 &mut self,
341 event: &ErasedEditorEvent,
342 _window: &mut Window,
343 cx: &mut Context<Self>,
344 ) {
345 if matches!(event, ErasedEditorEvent::BufferEdited) {
346 self.recompute_name_error(cx);
347 self.save_error = None;
348 cx.notify();
349 }
350 }
351
352 fn handle_description_input_event(
353 &mut self,
354 event: &ErasedEditorEvent,
355 _window: &mut Window,
356 cx: &mut Context<Self>,
357 ) {
358 if matches!(event, ErasedEditorEvent::BufferEdited) {
359 self.recompute_description_error(cx);
360 self.save_error = None;
361 cx.notify();
362 }
363 }
364
365 fn handle_body_editor_event(
366 &mut self,
367 _: &Entity<Editor>,
368 event: &EditorEvent,
369 _window: &mut Window,
370 cx: &mut Context<Self>,
371 ) {
372 if matches!(event, EditorEvent::BufferEdited) {
373 self.recompute_body_error(cx);
374 self.save_error = None;
375 cx.notify();
376 }
377 }
378
379 fn current_name(&self, cx: &App) -> String {
380 self.name_editor.read(cx).text(cx)
381 }
382
383 fn current_description(&self, cx: &App) -> String {
384 self.description_editor.read(cx).text(cx)
385 }
386
387 fn current_body(&self, cx: &App) -> String {
388 self.body_editor.read(cx).text(cx)
389 }
390
391 fn current_url(&self, cx: &App) -> String {
392 self.url_editor.read(cx).text(cx)
393 }
394
395 fn recompute_name_error(&mut self, cx: &mut Context<Self>) {
396 let name = self.current_name(cx);
397 let error = validate_name(&name).err();
398 self.name_error = error;
399 self.name_editor
400 .update(cx, |field, cx| field.set_error(error, cx));
401 }
402
403 fn recompute_description_error(&mut self, cx: &mut Context<Self>) {
404 let description = self.current_description(cx);
405 self.description_length = description.len();
406 let error = validate_description(&description).err();
407 self.description_error = error;
408 self.description_editor
409 .update(cx, |field, cx| field.set_error(error, cx));
410 }
411
412 fn recompute_body_error(&mut self, cx: &App) {
413 let body = self.current_body(cx);
414 self.body_error = if body.trim().is_empty() {
415 Some("Body is required.")
416 } else {
417 None
418 };
419 }
420
421 fn is_valid(&self, cx: &App) -> bool {
422 validate_name(&self.current_name(cx)).is_ok()
423 && validate_description(&self.current_description(cx)).is_ok()
424 && !self.current_body(cx).trim().is_empty()
425 }
426
427 pub(crate) fn apply_open_mode(
428 &mut self,
429 open_mode: SkillCreatorOpenMode,
430 window: &mut Window,
431 cx: &mut Context<Self>,
432 ) {
433 match open_mode {
434 SkillCreatorOpenMode::Form => {}
435 SkillCreatorOpenMode::Url { initial_url } => {
436 self.open_url_import(initial_url, window, cx);
437 }
438 SkillCreatorOpenMode::Install { content } => {
439 self.open_install_review(content, window, cx);
440 }
441 }
442 }
443
444 /// Pre-fill the form with a skill supplied inline (from a share link) so
445 /// the recipient can review it before saving. Unlike URL import, this
446 /// doesn't touch the URL editor or perform any network request.
447 fn open_install_review(
448 &mut self,
449 content: String,
450 window: &mut Window,
451 cx: &mut Context<Self>,
452 ) {
453 self.url_import_debounce_task = None;
454 self.url_import_task = None;
455 self.url_import_status = UrlImportStatus::Idle;
456
457 match parse_imported_skill(&content, "") {
458 Ok(imported) => self.apply_imported_skill(imported, window, cx),
459 Err(err) => {
460 self.save_error = Some(SharedString::from(format!(
461 "Couldn't read shared skill: {err}"
462 )));
463 cx.notify();
464 }
465 }
466 }
467
468 fn open_url_import(
469 &mut self,
470 initial_url: Option<String>,
471 window: &mut Window,
472 cx: &mut Context<Self>,
473 ) {
474 self.save_error = None;
475 self.url_import_debounce_task = None;
476 self.url_import_task = None;
477 self.url_import_status = UrlImportStatus::Idle;
478
479 let text = initial_url.unwrap_or_default();
480 let should_fetch = !text.is_empty();
481 let needs_set_text = should_fetch || !self.current_url(cx).is_empty();
482 if !needs_set_text {
483 // No text to write and nothing to clear: just move focus.
484 window.focus(&self.url_editor.focus_handle(cx), cx);
485 cx.notify();
486 return;
487 }
488
489 // Defer so the programmatic `set_text` runs before we move focus
490 // to the URL editor. `handle_url_input_event` uses
491 // `url_editor.is_focused(window)` to distinguish user edits from
492 // programmatic ones, so writing while unfocused is what keeps the
493 // synthesized `BufferEdited` from being treated as a user edit.
494 let skill_creator = cx.weak_entity();
495 let url_editor = self.url_editor.clone();
496 window.defer(cx, move |window, cx| {
497 url_editor.update(cx, |input, cx| {
498 input.set_text(&text, window, cx);
499 });
500 window.focus(&url_editor.focus_handle(cx), cx);
501 if should_fetch {
502 skill_creator
503 .update(cx, |this, cx| {
504 this.start_url_import(window, cx);
505 })
506 .log_err();
507 }
508 });
509 cx.notify();
510 }
511
512 fn schedule_url_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
513 self.url_import_debounce_task = None;
514 self.url_import_task = None;
515
516 let url = self.current_url(cx).trim().to_string();
517 if url.is_empty() {
518 self.url_import_status = UrlImportStatus::Idle;
519 cx.notify();
520 return;
521 }
522
523 self.url_import_status = UrlImportStatus::Idle;
524 let task = cx.spawn_in(window, async move |this, cx| {
525 cx.background_executor().timer(URL_IMPORT_DEBOUNCE).await;
526 this.update_in(cx, |this, window, cx| {
527 this.start_url_import(window, cx);
528 })
529 .log_err();
530 });
531 self.url_import_debounce_task = Some(task);
532 cx.notify();
533 }
534
535 fn start_url_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
536 // Cancel any pending debounce so the explicit start supersedes it,
537 // instead of racing with a timer that's about to fire.
538 self.url_import_debounce_task = None;
539 self.url_import_task = None;
540
541 let url = self.current_url(cx).trim().to_string();
542 if url.is_empty() {
543 self.url_import_status = UrlImportStatus::Idle;
544 cx.notify();
545 return;
546 }
547
548 if let Err(err) = github_raw_url(&url) {
549 self.url_import_status = UrlImportStatus::Error(SharedString::from(err.to_string()));
550 cx.notify();
551 return;
552 }
553
554 self.url_import_status = UrlImportStatus::Fetching;
555 let http_client = self.http_client.clone();
556 let fetch_task = cx.background_spawn(fetch_imported_skill_from_url(http_client, url));
557 let task = cx.spawn_in(window, async move |this, cx| {
558 let result = fetch_task.await;
559 this.update_in(cx, |this, window, cx| {
560 this.url_import_debounce_task = None;
561 this.url_import_task = None;
562 match result {
563 Ok(imported) => {
564 this.apply_imported_skill(imported, window, cx);
565 }
566 Err(err) => {
567 this.url_import_status =
568 UrlImportStatus::Error(SharedString::from(err.to_string()));
569 cx.notify();
570 }
571 }
572 })
573 .log_err();
574 });
575 self.url_import_task = Some(task);
576 cx.notify();
577 }
578
579 fn apply_imported_skill(
580 &mut self,
581 imported: ImportedSkill,
582 window: &mut Window,
583 cx: &mut Context<Self>,
584 ) {
585 self.url_import_status = UrlImportStatus::Idle;
586 self.save_error = None;
587
588 let name_editor = self.name_editor.clone();
589 let description_editor = self.description_editor.clone();
590 let body_editor = self.body_editor.clone();
591 let skill_creator = cx.weak_entity();
592 window.defer(cx, move |window, cx| {
593 name_editor.update(cx, |input, cx| {
594 input.set_text(&imported.name, window, cx);
595 });
596 description_editor.update(cx, |input, cx| {
597 input.set_text(&imported.description, window, cx);
598 });
599 body_editor.update(cx, |editor, cx| {
600 editor.set_text(imported.body.clone(), window, cx);
601 });
602 skill_creator
603 .update(cx, |this, cx| {
604 this.disable_model_invocation = imported.disable_model_invocation;
605 this.url_import_status = UrlImportStatus::Idle;
606 this.url_import_debounce_task = None;
607 this.url_import_task = None;
608 this.save_error = None;
609 this.recompute_name_error(cx);
610 this.recompute_description_error(cx);
611 this.recompute_body_error(cx);
612 cx.notify();
613 })
614 .log_err();
615 window.focus(&name_editor.focus_handle(cx), cx);
616 });
617 }
618
619 fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context<Self>) {
620 self.recompute_name_error(cx);
621 self.recompute_description_error(cx);
622 self.recompute_body_error(cx);
623
624 if !self.is_valid(cx) || self.saving {
625 cx.notify();
626 return;
627 }
628
629 // Resolve the scope at save time so the skill is written to whichever
630 // settings file is selected at the moment the user clicks Save.
631 let scope = self
632 .settings_window
633 .read_with(cx, |settings_window, cx| {
634 scope_for_settings_file(
635 &settings_window.current_file,
636 settings_window.original_window.as_ref(),
637 cx,
638 )
639 })
640 .unwrap_or(ScopeChoice::Global);
641 let name = self.current_name(cx);
642 let description = self.current_description(cx);
643 let body = self.current_body(cx);
644 let disable_model_invocation = self.disable_model_invocation;
645 let fs = self.fs.clone();
646
647 self.saving = true;
648 self.save_error = None;
649 cx.notify();
650
651 let task = cx.spawn_in(window, async move |this, cx| {
652 let result = write_skill_to_disk(
653 fs.as_ref(),
654 &scope.skills_dir(),
655 &name,
656 &description,
657 &body,
658 disable_model_invocation,
659 )
660 .await;
661
662 this.update_in(cx, |this, _window, cx| {
663 this.saving = false;
664 this.save_task = None;
665 match result {
666 Ok(_) => {
667 // Rescan skill directories so new skills show up in Settings page right away
668 if let Some(hook) = cx.try_global::<SkillsUpdatedHook>() {
669 let hook = hook.0.clone();
670 hook(cx);
671 }
672
673 cx.emit(SkillCreatorEvent::Saved);
674 }
675 Err(err) => {
676 this.save_error = Some(SharedString::from(err.to_string()));
677 cx.notify();
678 }
679 }
680 })
681 .log_err();
682 });
683 self.save_task = Some(task);
684 }
685
686 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
687 // Block dismissal while a save is in flight
688 if self.saving {
689 return;
690 }
691 cx.emit(SkillCreatorEvent::Dismissed);
692 }
693
694 fn toggle_disable_model_invocation(&mut self, cx: &mut Context<Self>) {
695 self.disable_model_invocation = !self.disable_model_invocation;
696 cx.notify();
697 }
698
699 fn render_url_import(&self) -> impl IntoElement {
700 v_flex()
701 .flex_shrink_0()
702 .gap_2()
703 .child(
704 h_flex()
705 .gap_1()
706 .child(Label::new("Import from URL"))
707 .child(Label::new("(optional)").color(Color::Muted)),
708 )
709 .child(self.url_editor.clone())
710 .child(match &self.url_import_status {
711 UrlImportStatus::Idle => Label::new(
712 "Paste a GitHub .md URL to fetch it and fill out the form. \
713 For private files, Omega retries using GITHUB_TOKEN, if set.",
714 )
715 .size(LabelSize::Small)
716 .color(Color::Muted)
717 .into_any_element(),
718 UrlImportStatus::Fetching => {
719 LoadingLabel::new("Fetching and parsing…").into_any_element()
720 }
721 UrlImportStatus::Error(error) => h_flex()
722 .gap_1()
723 .child(
724 Icon::new(IconName::XCircle)
725 .size(IconSize::Small)
726 .color(Color::Error),
727 )
728 .child(
729 Label::new(error.clone())
730 .size(LabelSize::Small)
731 .color(Color::Error),
732 )
733 .into_any_element(),
734 })
735 }
736
737 fn render_form_fields(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
738 v_flex()
739 .id("skill-creator-form-fields")
740 .flex_grow_1()
741 .flex_shrink_0()
742 .gap_4()
743 .child(
744 v_flex()
745 .gap_2()
746 .child(Label::new("Front-matter"))
747 .child(self.name_editor.clone())
748 .child(self.description_editor.clone()),
749 )
750 .child(self.render_optional_params(cx))
751 .child(Divider::horizontal())
752 .child(
753 v_flex()
754 .flex_grow_1()
755 .flex_shrink_0()
756 .gap_2()
757 .child(Label::new("Skill Content"))
758 .child(self.render_body_field(window, cx))
759 .when_some(self.body_error, |this, error| {
760 this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
761 }),
762 )
763 }
764
765 fn render_optional_params(&self, cx: &mut Context<Self>) -> impl IntoElement {
766 let toggle_state: ToggleState = self.disable_model_invocation.into();
767
768 SwitchField::new(
769 "disable-model-invocation",
770 Some("Disable model invocation"),
771 Some(
772 "Hide this skill from the model's catalog. It can still be invoked via slash command."
773 .into(),
774 ),
775 toggle_state,
776 cx.listener(|this, _state: &ToggleState, _window, cx| {
777 this.toggle_disable_model_invocation(cx);
778 }),
779 )
780 .tab_index(DISABLE_MODEL_INVOCATION_TAB_INDEX)
781 .into_any_element()
782 }
783
784 fn render_body_field(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
785 let settings = ThemeSettings::get_global(cx);
786 let theme = cx.theme().clone();
787
788 let has_error = self.body_error.is_some();
789
790 let focus_handle = self
791 .body_editor
792 .focus_handle(cx)
793 .tab_index(BODY_FIELD_TAB_INDEX)
794 .tab_stop(true);
795
796 let border_color = if has_error {
797 theme.status().error_border
798 } else if focus_handle.contains_focused(window, cx) {
799 theme.colors().border_focused
800 } else {
801 theme.colors().border
802 };
803
804 div()
805 .w_full()
806 .flex_1()
807 .min_h(px(160.))
808 .p_2p5()
809 .rounded_md()
810 .border_1()
811 .border_color(border_color)
812 .bg(theme.colors().editor_background)
813 .track_focus(&focus_handle)
814 .overflow_hidden()
815 .child(EditorElement::new(
816 &self.body_editor,
817 EditorStyle {
818 local_player: theme.players().local(),
819 text: TextStyle {
820 color: theme.colors().text,
821 font_family: settings.buffer_font.family.clone(),
822 font_features: settings.buffer_font.features.clone(),
823 font_size: rems(0.875).into(),
824 font_weight: settings.buffer_font.weight,
825 line_height: relative(settings.buffer_line_height.value()),
826 ..Default::default()
827 },
828 syntax: theme.syntax().clone(),
829 inlay_hints_style: editor::make_inlay_hints_style(cx),
830 edit_prediction_styles: editor::make_suggestion_styles(cx),
831 ..EditorStyle::default()
832 },
833 ))
834 }
835
836 fn render_footer(&self, _window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
837 let saving = self.saving;
838 let main_action = if saving { "Saving…" } else { "Save Skill" };
839
840 v_flex()
841 .w_full()
842 .py_2p5()
843 .px_8()
844 .border_t_1()
845 .border_color(cx.theme().colors().border_variant.opacity(0.4))
846 .when(self.save_error.is_some(), |this| {
847 this.gap_2().child(
848 Banner::new()
849 .severity(Severity::Error)
850 .children(self.save_error.clone().map(|err| Label::new(err))),
851 )
852 })
853 .child(
854 h_flex().w_full().gap_1().justify_end().child(
855 Button::new("save-skill", main_action)
856 .size(ButtonSize::Medium)
857 .style(ButtonStyle::Outlined)
858 .loading(saving)
859 .tab_index(SAVE_BUTTON_TAB_INDEX)
860 // Call `save_skill` directly instead of dispatching the
861 // `SaveSkill` action: action dispatch follows the focused
862 // element's path, so a dispatched action is silently
863 // dropped whenever focus is outside the creator (e.g.
864 // right after switching the settings file/scope).
865 .on_click(cx.listener(|this, _, window, cx| {
866 this.save_skill(&SaveSkill, window, cx);
867 })),
868 ),
869 )
870 }
871
872 fn focus_next_field(
873 &mut self,
874 _: &FocusNextField,
875 window: &mut Window,
876 cx: &mut Context<Self>,
877 ) {
878 window.focus_next(cx);
879 }
880
881 fn focus_previous_field(
882 &mut self,
883 _: &FocusPreviousField,
884 window: &mut Window,
885 cx: &mut Context<Self>,
886 ) {
887 window.focus_prev(cx);
888 }
889
890 fn on_menu_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
891 window.focus_next(cx);
892 }
893
894 fn on_menu_prev(
895 &mut self,
896 _: &menu::SelectPrevious,
897 window: &mut Window,
898 cx: &mut Context<Self>,
899 ) {
900 window.focus_prev(cx);
901 }
902}
903
904impl Focusable for SkillCreatorPage {
905 fn focus_handle(&self, _cx: &App) -> FocusHandle {
906 self.focus_handle.clone()
907 }
908}
909
910impl Render for SkillCreatorPage {
911 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
912 v_flex()
913 .id("skill-creator")
914 .key_context("SkillCreator")
915 .track_focus(&self.focus_handle)
916 .on_action(
917 |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| {
918 for action in &action_sequence.0 {
919 window.dispatch_action(action.boxed_clone(), cx);
920 }
921 },
922 )
923 .on_action(cx.listener(Self::save_skill))
924 .on_action(cx.listener(Self::cancel))
925 .on_action(cx.listener(Self::focus_next_field))
926 .on_action(cx.listener(Self::focus_previous_field))
927 .on_action(cx.listener(Self::on_menu_next))
928 .on_action(cx.listener(Self::on_menu_prev))
929 .size_full()
930 .overflow_hidden()
931 .child(
932 div()
933 .flex_1()
934 .min_h_0()
935 .w_full()
936 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
937 .child(
938 v_flex()
939 .id("skill-creator-form")
940 .tab_index(0)
941 .tab_group()
942 .tab_stop(false)
943 .size_full()
944 .overflow_y_scroll()
945 .track_scroll(&self.scroll_handle)
946 .gap_4()
947 .px_8()
948 .py_4()
949 .child(self.render_url_import())
950 .child(Divider::horizontal().flex_shrink_0().flex_grow_1())
951 .child(self.render_form_fields(window, cx)),
952 ),
953 )
954 .child(self.render_footer(window, cx))
955 }
956}
957
958async fn fetch_imported_skill_from_url(
959 http_client: Arc<dyn HttpClient>,
960 url: String,
961) -> Result<ImportedSkill> {
962 let github_token = std::env::var("GITHUB_TOKEN").ok().and_then(|token| {
963 let token = token.trim().to_string();
964 (!token.is_empty()).then_some(token)
965 });
966 fetch_imported_skill_from_url_with_github_token(http_client, url, github_token).await
967}
968
969async fn fetch_imported_skill_from_url_with_github_token(
970 http_client: Arc<dyn HttpClient>,
971 url: String,
972 github_token: Option<String>,
973) -> Result<ImportedSkill> {
974 let raw_url = github_raw_url(&url)?;
975 let (mut status, mut body) =
976 fetch_skill_url(http_client.clone(), raw_url.as_str(), None).await?;
977
978 if status == StatusCode::NOT_FOUND
979 && let Some(github_token) = github_token.as_deref()
980 {
981 (status, body) = fetch_skill_url(http_client, raw_url.as_str(), Some(github_token)).await?;
982 }
983
984 if !status.is_success() {
985 return Err(github_fetch_error(status, &body));
986 }
987
988 if body.len() > MAX_SKILL_FILE_SIZE {
989 anyhow::bail!(
990 "SKILL.md file exceeds maximum size of {}KB",
991 MAX_SKILL_FILE_SIZE / 1024
992 );
993 }
994
995 let content = String::from_utf8(body).context("GitHub response was not valid UTF-8")?;
996 parse_imported_skill(&content, raw_url.as_str())
997}
998
999async fn fetch_skill_url(
1000 http_client: Arc<dyn HttpClient>,
1001 raw_url: &str,
1002 github_token: Option<&str>,
1003) -> Result<(StatusCode, Vec<u8>)> {
1004 // When sending the GitHub token, don't follow redirects: whether an
1005 // `Authorization` header survives a cross-origin redirect depends on the
1006 // underlying `HttpClient` implementation, and a redirect away from
1007 // raw.githubusercontent.com must never carry the user's token with it.
1008 // Authenticated raw.githubusercontent.com responses are served directly,
1009 // so a redirect on that path is unexpected anyway.
1010 let redirect_policy = if github_token.is_some() {
1011 http_client::RedirectPolicy::NoFollow
1012 } else {
1013 http_client::RedirectPolicy::FollowAll
1014 };
1015 let request = Request::get(raw_url)
1016 .follow_redirects(redirect_policy)
1017 .when_some(github_token, |builder, token| {
1018 builder.header("Authorization", format!("Bearer {token}"))
1019 })
1020 .body(AsyncBody::default())?;
1021
1022 let mut response = http_client
1023 .send(request)
1024 .await
1025 .with_context(|| format!("failed to fetch {raw_url}"))?;
1026
1027 let status = response.status();
1028 if github_token.is_some() && status.is_redirection() {
1029 anyhow::bail!(
1030 "GitHub returned an unexpected redirect ({}) for the authenticated request to {raw_url}",
1031 status.as_u16()
1032 );
1033 }
1034 let mut body = Vec::new();
1035 response
1036 .body_mut()
1037 .take(MAX_SKILL_FILE_SIZE as u64 + 1)
1038 .read_to_end(&mut body)
1039 .await
1040 .context("failed to read response body")?;
1041
1042 Ok((status, body))
1043}
1044
1045fn github_fetch_error(status: StatusCode, body: &[u8]) -> anyhow::Error {
1046 let mut message = if status == StatusCode::NOT_FOUND {
1047 "GitHub returned 404 while fetching the skill; no repository exists at this URL, or it is private"
1048 .to_string()
1049 } else {
1050 format!(
1051 "GitHub returned {} while fetching the skill",
1052 status.as_u16()
1053 )
1054 };
1055
1056 let response_text = truncated_response_body_for_error(body);
1057 if !response_text.is_empty() {
1058 message.push_str(": ");
1059 message.push_str(&response_text);
1060 }
1061
1062 anyhow!(message)
1063}
1064
1065pub(crate) fn is_supported_skill_url(input: &str) -> bool {
1066 github_raw_url(input).is_ok()
1067}
1068
1069fn github_raw_url(input: &str) -> Result<String> {
1070 let url = Url::parse(input.trim()).context("Enter a valid GitHub URL")?;
1071 if url.scheme() != "https" {
1072 anyhow::bail!("GitHub skill URLs must use https://");
1073 }
1074
1075 let host = url
1076 .host_str()
1077 .ok_or_else(|| anyhow!("Enter a valid GitHub URL"))?;
1078 let path_segments = url
1079 .path_segments()
1080 .ok_or_else(|| anyhow!("Enter a valid GitHub URL"))?
1081 .collect::<Vec<_>>();
1082
1083 match host {
1084 "github.com" => github_blob_raw_url(&path_segments),
1085 "raw.githubusercontent.com" => {
1086 ensure_markdown_path(&path_segments)?;
1087 Ok(url.into())
1088 }
1089 _ => anyhow::bail!("Paste a GitHub .md URL"),
1090 }
1091}
1092
1093fn github_blob_raw_url(path_segments: &[&str]) -> Result<String> {
1094 let [owner, repo, kind, reference, file_path @ ..] = path_segments else {
1095 anyhow::bail!("Paste a GitHub blob URL that points to a .md file");
1096 };
1097
1098 if *kind != "blob" {
1099 anyhow::bail!("Paste a GitHub blob URL that points to a .md file");
1100 }
1101
1102 ensure_markdown_path(file_path)?;
1103 Ok(format!(
1104 "https://raw.githubusercontent.com/{owner}/{repo}/{reference}/{}",
1105 file_path.join("/")
1106 ))
1107}
1108
1109fn ensure_markdown_path(path_segments: &[&str]) -> Result<()> {
1110 let Some(file_name) = path_segments.last() else {
1111 anyhow::bail!("Paste a GitHub .md URL");
1112 };
1113
1114 if !file_name.to_ascii_lowercase().ends_with(".md") {
1115 anyhow::bail!("Paste a GitHub URL that points to a .md file");
1116 }
1117
1118 Ok(())
1119}
1120
1121fn parse_imported_skill(content: &str, source_url: &str) -> Result<ImportedSkill> {
1122 if content.trim_start().starts_with("---") {
1123 let (metadata, body) = parse_skill_file_content(content)?;
1124 return Ok(ImportedSkill {
1125 name: metadata.name,
1126 description: metadata.description,
1127 body: body.trim().to_string(),
1128 disable_model_invocation: metadata.disable_model_invocation,
1129 });
1130 }
1131
1132 Ok(ImportedSkill {
1133 name: derived_skill_name_from_url(source_url).unwrap_or_else(|| "imported-skill".into()),
1134 description: derived_description_from_markdown(content).unwrap_or_default(),
1135 body: content.trim().to_string(),
1136 disable_model_invocation: false,
1137 })
1138}
1139
1140fn derived_skill_name_from_url(source_url: &str) -> Option<String> {
1141 let url = Url::parse(source_url).ok()?;
1142 let file_name = url.path_segments()?.next_back()?;
1143 let stem = file_name
1144 .rsplit_once('.')
1145 .and_then(|(stem, extension)| extension.eq_ignore_ascii_case("md").then_some(stem))
1146 .unwrap_or(file_name);
1147 slugify_skill_name(stem)
1148}
1149
1150fn truncated_response_body_for_error(body: &[u8]) -> String {
1151 let text = String::from_utf8_lossy(body);
1152 let text = text.trim();
1153 if text.len() <= URL_IMPORT_ERROR_BODY_MAX_LEN {
1154 return text.to_string();
1155 }
1156
1157 let mut end = URL_IMPORT_ERROR_BODY_MAX_LEN;
1158 while !text.is_char_boundary(end) {
1159 end -= 1;
1160 }
1161 format!("{}…", text[..end].trim_end())
1162}
1163
1164fn derived_description_from_markdown(content: &str) -> Option<String> {
1165 content.lines().find_map(|line| {
1166 let line = line.trim();
1167 if line.is_empty() || line == "---" {
1168 return None;
1169 }
1170
1171 let text = line.trim_start_matches('#').trim();
1172 if text.is_empty() {
1173 None
1174 } else {
1175 Some(truncate_description(text))
1176 }
1177 })
1178}
1179
1180fn truncate_description(description: &str) -> String {
1181 if description.len() <= MAX_SKILL_DESCRIPTION_LEN {
1182 return description.to_string();
1183 }
1184
1185 let mut end = MAX_SKILL_DESCRIPTION_LEN;
1186 while !description.is_char_boundary(end) {
1187 end -= 1;
1188 }
1189 description[..end].trim().to_string()
1190}
1191
1192/// Serialize the SKILL.md file to disk at `<skills_dir>/<name>/SKILL.md`.
1193///
1194/// Refuses to overwrite an existing directory at `<skills_dir>/<name>`. The
1195/// caller surfaces the resulting error to the user, who picks a different
1196/// name.
1197async fn write_skill_to_disk(
1198 fs: &dyn Fs,
1199 skills_dir: &std::path::Path,
1200 name: &str,
1201 description: &str,
1202 body: &str,
1203 disable_model_invocation: bool,
1204) -> Result<PathBuf> {
1205 let skill_dir = skills_dir.join(name);
1206 match fs.metadata(&skill_dir).await {
1207 Ok(Some(metadata)) if metadata.is_dir => {
1208 anyhow::bail!(
1209 "A skill named \"{name}\" already exists at {}. Pick a different name.",
1210 skill_dir.display()
1211 );
1212 }
1213 Ok(Some(_)) => {
1214 // Something exists at this path, but it isn't a directory — e.g.
1215 // a stray file the user (or another tool) left there. Without
1216 // this branch we'd fall through to `create_dir`, which on the
1217 // real fs returns a generic "File exists" IO error that gives
1218 // the user no idea what's wrong or how to recover.
1219 anyhow::bail!(
1220 "A file (not a skill directory) already exists at {}. \
1221 Delete it or pick a different skill name.",
1222 skill_dir.display()
1223 );
1224 }
1225 Ok(None) => {}
1226 Err(err) => {
1227 return Err(err).with_context(|| {
1228 format!(
1229 "failed to check whether {} already exists",
1230 skill_dir.display()
1231 )
1232 });
1233 }
1234 }
1235
1236 let content = format_skill_file(name, description, body, disable_model_invocation)?;
1237
1238 fs.create_dir(&skill_dir)
1239 .await
1240 .with_context(|| format!("failed to create skill directory {}", skill_dir.display()))?;
1241 let skill_file_path = skill_dir.join(SKILL_FILE_NAME);
1242 fs.write(&skill_file_path, content.as_bytes())
1243 .await
1244 .with_context(|| format!("failed to write {}", skill_file_path.display()))?;
1245
1246 Ok(skill_file_path)
1247}
1248
1249fn format_skill_file(
1250 name: &str,
1251 description: &str,
1252 body: &str,
1253 disable_model_invocation: bool,
1254) -> Result<String> {
1255 let metadata = SkillMetadata {
1256 name: name.to_string(),
1257 description: description.to_string(),
1258 disable_model_invocation,
1259 };
1260 let frontmatter = serde_yaml_ng::to_string(&metadata)
1261 .context("failed to serialize skill frontmatter as YAML")?;
1262
1263 let mut content = String::with_capacity(frontmatter.len() + body.len() + 16);
1264 content.push_str("---\n");
1265 content.push_str(&frontmatter);
1266 content.push_str("---\n");
1267 let trimmed_body = body.trim();
1268 if !trimmed_body.is_empty() {
1269 content.push('\n');
1270 content.push_str(trimmed_body);
1271 content.push('\n');
1272 }
1273 Ok(content)
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use agent_skills::{SkillSource, parse_skill_frontmatter};
1280 use fs::FakeFs;
1281 use std::{
1282 collections::VecDeque,
1283 io,
1284 path::Path,
1285 pin::Pin,
1286 sync::{Arc, Mutex},
1287 task::{self, Poll},
1288 };
1289
1290 struct TestHttpClient {
1291 responses: Mutex<VecDeque<(StatusCode, AsyncBody)>>,
1292 authorization_headers: Mutex<Vec<Option<String>>>,
1293 redirect_policies: Mutex<Vec<http_client::RedirectPolicy>>,
1294 }
1295
1296 impl TestHttpClient {
1297 fn new(status: u16, body: AsyncBody) -> Arc<dyn HttpClient> {
1298 Self::new_sequence(vec![(status, body)])
1299 }
1300
1301 fn new_sequence(responses: Vec<(u16, AsyncBody)>) -> Arc<Self> {
1302 Arc::new(Self {
1303 responses: Mutex::new(
1304 responses
1305 .into_iter()
1306 .map(|(status, body)| {
1307 (
1308 StatusCode::from_u16(status)
1309 .expect("test status code should be valid"),
1310 body,
1311 )
1312 })
1313 .collect(),
1314 ),
1315 authorization_headers: Mutex::new(Vec::new()),
1316 redirect_policies: Mutex::new(Vec::new()),
1317 })
1318 }
1319
1320 fn authorization_headers(&self) -> Vec<Option<String>> {
1321 self.authorization_headers
1322 .lock()
1323 .expect("authorization header mutex should not be poisoned")
1324 .clone()
1325 }
1326
1327 fn redirect_policies(&self) -> Vec<http_client::RedirectPolicy> {
1328 self.redirect_policies
1329 .lock()
1330 .expect("redirect policy mutex should not be poisoned")
1331 .clone()
1332 }
1333 }
1334
1335 impl HttpClient for TestHttpClient {
1336 fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
1337 None
1338 }
1339
1340 fn proxy(&self) -> Option<&Url> {
1341 None
1342 }
1343
1344 fn send(
1345 &self,
1346 req: http_client::Request<AsyncBody>,
1347 ) -> futures::future::BoxFuture<'static, Result<http_client::Response<AsyncBody>>> {
1348 let authorization_header = req
1349 .headers()
1350 .get("Authorization")
1351 .and_then(|header| header.to_str().ok())
1352 .map(ToString::to_string);
1353
1354 match self.authorization_headers.lock() {
1355 Ok(mut authorization_headers) => authorization_headers.push(authorization_header),
1356 Err(_) => {
1357 return Box::pin(async {
1358 Err(anyhow::anyhow!(
1359 "test authorization header mutex was poisoned"
1360 ))
1361 });
1362 }
1363 }
1364
1365 let redirect_policy = req
1366 .extensions()
1367 .get::<http_client::RedirectPolicy>()
1368 .cloned()
1369 .unwrap_or_default();
1370 match self.redirect_policies.lock() {
1371 Ok(mut redirect_policies) => redirect_policies.push(redirect_policy),
1372 Err(_) => {
1373 return Box::pin(async {
1374 Err(anyhow::anyhow!("test redirect policy mutex was poisoned"))
1375 });
1376 }
1377 }
1378
1379 let response = match self.responses.lock() {
1380 Ok(mut responses) => responses.pop_front(),
1381 Err(_) => {
1382 return Box::pin(async {
1383 Err(anyhow::anyhow!("test response body mutex was poisoned"))
1384 });
1385 }
1386 };
1387 let Some((status, body)) = response else {
1388 return Box::pin(async {
1389 Err(anyhow::anyhow!("test response body was already consumed"))
1390 });
1391 };
1392
1393 Box::pin(async move {
1394 http_client::Response::builder()
1395 .status(status)
1396 .body(body)
1397 .map_err(anyhow::Error::new)
1398 })
1399 }
1400 }
1401
1402 struct FailsAfterLimitReader {
1403 bytes_read: usize,
1404 limit: usize,
1405 }
1406
1407 impl futures::AsyncRead for FailsAfterLimitReader {
1408 fn poll_read(
1409 mut self: Pin<&mut Self>,
1410 _cx: &mut task::Context<'_>,
1411 buffer: &mut [u8],
1412 ) -> Poll<io::Result<usize>> {
1413 if self.bytes_read >= self.limit {
1414 return Poll::Ready(Err(io::Error::other("read past limit")));
1415 }
1416
1417 let byte_count = buffer.len().min(self.limit - self.bytes_read);
1418 buffer[..byte_count].fill(b'a');
1419 self.bytes_read += byte_count;
1420 Poll::Ready(Ok(byte_count))
1421 }
1422 }
1423
1424 // Name and description validation rules are unit-tested in
1425 // `agent_skills`, which owns `validate_name` / `validate_description`
1426 // / `MAX_SKILL_DESCRIPTION_LEN`. The tests below cover the skill
1427 // creator's own surface area: SKILL.md formatting and disk-writing.
1428
1429 #[test]
1430 fn format_skill_file_round_trips_through_parser() {
1431 let content =
1432 format_skill_file("draft-pr", "Push a draft PR", "Do the thing.", false).unwrap();
1433 let skill = parse_skill_frontmatter(
1434 Path::new("/skills/draft-pr/SKILL.md"),
1435 &content,
1436 SkillSource::Global,
1437 )
1438 .expect("generated frontmatter must round-trip through parse_skill_frontmatter");
1439 assert_eq!(skill.name, "draft-pr");
1440 assert_eq!(skill.description, "Push a draft PR");
1441 assert!(!skill.disable_model_invocation);
1442 }
1443
1444 #[test]
1445 fn format_skill_file_writes_disable_model_invocation_true() {
1446 let content = format_skill_file("my-skill", "description", "body", true).unwrap();
1447 assert!(content.contains("disable-model-invocation: true"));
1448 }
1449
1450 #[test]
1451 fn format_skill_file_omits_body_when_empty() {
1452 let content = format_skill_file("my-skill", "description", " ", false).unwrap();
1453 // The trailing closing-delimiter newline is the last byte.
1454 assert!(content.ends_with("---\n"));
1455 }
1456
1457 #[test]
1458 fn format_skill_file_escapes_yaml_specials_in_description() {
1459 // serde_yaml_ng must quote/escape descriptions that contain YAML
1460 // specials so the file round-trips. If we ever swap formatters,
1461 // this test will catch a regression.
1462 let tricky = "contains: a colon, # a hash, and a \"quote\"";
1463 let content = format_skill_file("weird-skill", tricky, "body", false).unwrap();
1464 let skill = parse_skill_frontmatter(
1465 Path::new("/skills/weird-skill/SKILL.md"),
1466 &content,
1467 SkillSource::Global,
1468 )
1469 .expect("YAML-special characters must round-trip");
1470 assert_eq!(skill.description, tricky);
1471 }
1472
1473 #[test]
1474 fn github_blob_url_converts_to_raw_url() {
1475 let source_url = "https://github.com/cursor/plugins/blob/3347cbab5b54136f6fba0994c3a01a56f7fb7fca/cursor-team-kit/agents/thermo-nuclear-code-quality-review.md";
1476 let raw_url = github_raw_url(source_url).expect("GitHub blob URLs should be importable");
1477
1478 assert_eq!(
1479 raw_url,
1480 "https://raw.githubusercontent.com/cursor/plugins/3347cbab5b54136f6fba0994c3a01a56f7fb7fca/cursor-team-kit/agents/thermo-nuclear-code-quality-review.md"
1481 );
1482 assert!(is_supported_skill_url(source_url));
1483 assert!(!is_supported_skill_url(
1484 "https://example.com/not-a-skill.md"
1485 ));
1486 }
1487
1488 #[test]
1489 fn derived_skill_name_strips_markdown_extension_case_insensitively() {
1490 let name = derived_skill_name_from_url(
1491 "https://raw.githubusercontent.com/owner/repo/main/README.MD",
1492 )
1493 .expect("name should be derived from Markdown URL");
1494
1495 assert_eq!(name, "readme");
1496 }
1497
1498 #[test]
1499 fn parse_imported_skill_reads_frontmatter_and_body() {
1500 let imported = parse_imported_skill(
1501 "---\nname: imported-skill\ndescription: Imported from GitHub.\ndisable-model-invocation: true\n---\n\n# Instructions\n\nDo the thing.\n",
1502 "https://raw.githubusercontent.com/owner/repo/main/imported-skill.md",
1503 )
1504 .expect("valid skill frontmatter should parse");
1505
1506 assert_eq!(imported.name, "imported-skill");
1507 assert_eq!(imported.description, "Imported from GitHub.");
1508 assert_eq!(imported.body, "# Instructions\n\nDo the thing.");
1509 assert!(imported.disable_model_invocation);
1510 }
1511
1512 #[test]
1513 fn parse_imported_skill_falls_back_to_markdown_when_frontmatter_is_missing() {
1514 let imported = parse_imported_skill(
1515 "# Code Review\n\nReview code for maintainability.",
1516 "https://raw.githubusercontent.com/owner/repo/main/code-review.md",
1517 )
1518 .expect("plain markdown should still import");
1519
1520 assert_eq!(imported.name, "code-review");
1521 assert_eq!(imported.description, "Code Review");
1522 assert_eq!(
1523 imported.body,
1524 "# Code Review\n\nReview code for maintainability."
1525 );
1526 assert!(!imported.disable_model_invocation);
1527 }
1528
1529 #[test]
1530 fn parse_imported_skill_reuses_skill_metadata_validation() {
1531 let error = parse_imported_skill(
1532 "---\nname: Imported Skill\ndescription: Imported from GitHub.\n---\n\n# Instructions\n",
1533 "https://raw.githubusercontent.com/owner/repo/main/imported-skill.md",
1534 )
1535 .expect_err("invalid skill metadata should be rejected instead of imported");
1536 let message = error.to_string();
1537
1538 assert!(
1539 message.contains("Skill name must contain only lowercase letters"),
1540 "error should come from shared skill metadata validation, got: {message}"
1541 );
1542 }
1543
1544 #[gpui::test]
1545 async fn fetch_imported_skill_retries_404_with_github_token(_cx: &mut gpui::TestAppContext) {
1546 let client = TestHttpClient::new_sequence(vec![
1547 (404, AsyncBody::from("Not Found")),
1548 (200, AsyncBody::from("# Imported Skill\n\nDo the thing.")),
1549 ]);
1550
1551 let imported = fetch_imported_skill_from_url_with_github_token(
1552 client.clone(),
1553 "https://github.com/owner/repo/blob/main/skill.md".to_string(),
1554 Some("secret-token".to_string()),
1555 )
1556 .await
1557 .expect("private repo fallback should retry with the GitHub token");
1558
1559 assert_eq!(imported.name, "skill");
1560 assert_eq!(imported.description, "Imported Skill");
1561 assert_eq!(
1562 client.authorization_headers(),
1563 vec![None, Some("Bearer secret-token".to_string())]
1564 );
1565 assert_eq!(
1566 client.redirect_policies(),
1567 vec![
1568 http_client::RedirectPolicy::FollowAll,
1569 http_client::RedirectPolicy::NoFollow,
1570 ],
1571 "the authenticated retry must not follow redirects, so the token \
1572 can never be forwarded to another host"
1573 );
1574 }
1575
1576 #[gpui::test]
1577 async fn fetch_imported_skill_rejects_redirect_on_authenticated_request(
1578 _cx: &mut gpui::TestAppContext,
1579 ) {
1580 let client = TestHttpClient::new_sequence(vec![
1581 (404, AsyncBody::from("Not Found")),
1582 (302, AsyncBody::from("")),
1583 ]);
1584
1585 let error = fetch_imported_skill_from_url_with_github_token(
1586 client.clone(),
1587 "https://github.com/owner/repo/blob/main/skill.md".to_string(),
1588 Some("secret-token".to_string()),
1589 )
1590 .await
1591 .expect_err("a redirect on the authenticated request should be an error");
1592 let message = error.to_string();
1593
1594 assert!(
1595 message.contains("unexpected redirect (302)"),
1596 "error should report the redirect, got: {message}"
1597 );
1598 }
1599
1600 #[gpui::test]
1601 async fn fetch_imported_skill_reports_private_or_missing_for_404(
1602 _cx: &mut gpui::TestAppContext,
1603 ) {
1604 let client = TestHttpClient::new_sequence(vec![(404, AsyncBody::from("Not Found"))]);
1605
1606 let error = fetch_imported_skill_from_url_with_github_token(
1607 client.clone(),
1608 "https://github.com/owner/repo/blob/main/skill.md".to_string(),
1609 None,
1610 )
1611 .await
1612 .expect_err("404 without a GitHub token should fail");
1613 let message = error.to_string();
1614
1615 assert!(
1616 message.contains("no repository exists at this URL, or it is private"),
1617 "404 error should mention private repositories, got: {message}"
1618 );
1619 assert_eq!(client.authorization_headers(), vec![None]);
1620 }
1621
1622 #[gpui::test]
1623 async fn fetch_imported_skill_stops_reading_after_size_limit(_cx: &mut gpui::TestAppContext) {
1624 let client = TestHttpClient::new(
1625 200,
1626 AsyncBody::from_reader(FailsAfterLimitReader {
1627 bytes_read: 0,
1628 limit: MAX_SKILL_FILE_SIZE + 1,
1629 }),
1630 );
1631
1632 let error = fetch_imported_skill_from_url(
1633 client,
1634 "https://github.com/owner/repo/blob/main/skill.md".to_string(),
1635 )
1636 .await
1637 .expect_err("oversized responses should be rejected");
1638 let message = error.to_string();
1639
1640 assert!(
1641 message.contains("exceeds maximum size"),
1642 "error should report the skill size limit, got: {message}"
1643 );
1644 assert!(
1645 !message.contains("failed to read response body"),
1646 "reader should not be polled past the limit, got: {message}"
1647 );
1648 }
1649
1650 #[gpui::test]
1651 async fn fetch_imported_skill_truncates_error_response_body(_cx: &mut gpui::TestAppContext) {
1652 let body = format!(
1653 "{}tail-that-should-not-appear",
1654 "x".repeat(URL_IMPORT_ERROR_BODY_MAX_LEN + 20)
1655 );
1656 let client = TestHttpClient::new(500, AsyncBody::from(body));
1657
1658 let error = fetch_imported_skill_from_url(
1659 client,
1660 "https://github.com/owner/repo/blob/main/skill.md".to_string(),
1661 )
1662 .await
1663 .expect_err("non-success responses should be rejected");
1664 let message = error.to_string();
1665
1666 assert!(message.contains("GitHub returned 500"));
1667 assert!(
1668 message.ends_with('…'),
1669 "error body should be visibly truncated, got: {message}"
1670 );
1671 assert!(
1672 !message.contains("tail-that-should-not-appear"),
1673 "error body should not include the unbounded tail, got: {message}"
1674 );
1675 }
1676
1677 #[gpui::test]
1678 async fn write_skill_to_disk_creates_directory_and_file(cx: &mut gpui::TestAppContext) {
1679 let fs = FakeFs::new(cx.executor());
1680 fs.insert_tree("/skills", serde_json::json!({})).await;
1681
1682 let path = write_skill_to_disk(
1683 fs.as_ref(),
1684 Path::new("/skills"),
1685 "draft-pr",
1686 "Push a draft PR",
1687 "Body of the skill.",
1688 false,
1689 )
1690 .await
1691 .expect("write should succeed");
1692
1693 assert_eq!(path, Path::new("/skills/draft-pr/SKILL.md"));
1694 let content = fs.load(&path).await.expect("file should exist");
1695 let skill = parse_skill_frontmatter(&path, &content, SkillSource::Global)
1696 .expect("written file should be parseable");
1697 assert_eq!(skill.name, "draft-pr");
1698 assert_eq!(skill.description, "Push a draft PR");
1699 }
1700
1701 #[gpui::test]
1702 async fn write_skill_to_disk_refuses_to_overwrite(cx: &mut gpui::TestAppContext) {
1703 let fs = FakeFs::new(cx.executor());
1704 fs.insert_tree(
1705 "/skills",
1706 serde_json::json!({
1707 "draft-pr": {
1708 "SKILL.md": "---\nname: draft-pr\ndescription: existing\n---\nbody\n"
1709 }
1710 }),
1711 )
1712 .await;
1713
1714 let err = write_skill_to_disk(
1715 fs.as_ref(),
1716 Path::new("/skills"),
1717 "draft-pr",
1718 "Push a draft PR",
1719 "Body of the skill.",
1720 false,
1721 )
1722 .await
1723 .expect_err("writing over an existing skill must fail");
1724 assert!(
1725 err.to_string().contains("already exists"),
1726 "error message should mention the conflict, got: {err}"
1727 );
1728 }
1729
1730 #[gpui::test]
1731 async fn write_skill_to_disk_rejects_non_directory_at_skill_path(
1732 cx: &mut gpui::TestAppContext,
1733 ) {
1734 let fs = FakeFs::new(cx.executor());
1735 // A *file* (not a directory) sitting at `/skills/draft-pr`. With the
1736 // old `is_dir` check this slipped through and we ended up surfacing
1737 // the underlying "File exists" OS error.
1738 fs.insert_tree(
1739 "/skills",
1740 serde_json::json!({ "draft-pr": "i am a stray file" }),
1741 )
1742 .await;
1743
1744 let err = write_skill_to_disk(
1745 fs.as_ref(),
1746 Path::new("/skills"),
1747 "draft-pr",
1748 "Push a draft PR",
1749 "Body of the skill.",
1750 false,
1751 )
1752 .await
1753 .expect_err("writing where a file already lives must fail");
1754 let message = err.to_string();
1755 assert!(
1756 message.contains("not a skill directory"),
1757 "error should explain the conflict is a non-directory, got: {message}"
1758 );
1759 // Path separator differs between platforms
1760 let expected_path = Path::new("/skills").join("draft-pr");
1761 let expected_path = expected_path.display().to_string();
1762 assert!(
1763 message.contains(&expected_path),
1764 "error should include the conflicting path {expected_path:?}, got: {message}"
1765 );
1766 }
1767}
1768