Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:17.060Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

skill_creator.rs

1768 lines · 61.6 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.