feat: skills list UI and APIs

3clyp50 committed Feb 4, 2026 at 15:26 UTC 717f69f3971dfa90ab3e0d8879dbbe49312073b0
7 files changed +698 -16
python/api/skills.py new
+75
@@ -0,0 +1,75 @@
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +from python.helpers import skills
3 +
4 +
5 +def _coerce_bool(value) -> bool:
6 + if isinstance(value, bool):
7 + return value
8 + if isinstance(value, str):
9 + return value.strip().lower() in ("1", "true", "yes", "on")
10 + return bool(value)
11 +
12 +
13 +class Skills(ApiHandler):
14 + async def process(self, input: Input, request: Request) -> Output:
15 + action = input.get("action", "")
16 +
17 + try:
18 + if action == "list":
19 + data = self.list_skills(input)
20 + elif action == "toggle":
21 + data = self.toggle_skill(input)
22 + elif action == "delete":
23 + data = self.delete_skill(input)
24 + else:
25 + raise Exception("Invalid action")
26 +
27 + return {
28 + "ok": True,
29 + "data": data,
30 + }
31 + except Exception as e:
32 + return {
33 + "ok": False,
34 + "error": str(e),
35 + }
36 +
37 + def list_skills(self, input: Input):
38 + project_name = (input.get("project_name") or "").strip() or None
39 + profile_name = (input.get("profile_name") or "").strip() or None
40 + return skills.get_skills_list(
41 + project_name=project_name,
42 + profile_name=profile_name
43 + )
44 +
45 + def toggle_skill(self, input: Input):
46 + skill_id = str(input.get("skill_id") or "").strip()
47 + if not skill_id:
48 + raise Exception("skill_id is required")
49 +
50 + enabled = _coerce_bool(input.get("enabled", True))
51 + project_name = (input.get("project_name") or "").strip() or None
52 + profile_name = (input.get("profile_name") or "").strip() or None
53 +
54 + skills.set_skill_activation(
55 + skill_id,
56 + enabled=enabled,
57 + project_name=project_name,
58 + profile_name=profile_name,
59 + )
60 + return {"enabled": enabled}
61 +
62 + def delete_skill(self, input: Input):
63 + skill_id = str(input.get("skill_id") or "").strip()
64 + if not skill_id:
65 + raise Exception("skill_id is required")
66 +
67 + project_name = (input.get("project_name") or "").strip() or None
68 + profile_name = (input.get("profile_name") or "").strip() or None
69 +
70 + skills.delete_skill(
71 + skill_id,
72 + project_name=project_name,
73 + profile_name=profile_name,
74 + )
75 + return {"skill_id": skill_id}
python/extensions/system_prompt/_10_system_prompt.py
+1 -1
@@ -85,7 +85,7 @@ def get_project_prompt(agent: Agent):
85 return result
86
87 def get_skills_prompt(agent: Agent):
88 - available = skills.list_skills(agent)
88 + available = skills.list_skills(agent=agent, enabled_only=True)
89 result = []
90 for skill in available:
91 name = skill.name.strip().replace("\n", " ")[:100]
python/helpers/skills.py
+267 -14
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6 from pathlib import Path
7 from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, TYPE_CHECKING
8
9 -from python.helpers import files, subagents
9 +from python.helpers import files, subagents, dirty_json, projects
10
11 if TYPE_CHECKING:
12 from agent import Agent
@@ -286,7 +286,9 @@ def skill_from_markdown(
286 def list_skills(
287 agent:Agent|None=None,
288 include_content: bool = False,
289 + enabled_only: bool = False,
290 ) -> List[Skill]:
291 + """List skills, optionally filtered by agent scope and enabled status."""
292 skills: List[Skill] = []
293
294 roots = get_skill_roots(agent)
@@ -299,6 +301,8 @@ def list_skills(
301
302 # no deduplication for global skills
303 if not agent:
304 + if enabled_only:
305 + skills = filter_enabled_skills(skills, agent=None)
306 return skills
307
308 # Dedupe by normalized name, preserving root_order priority (earlier wins)
@@ -307,7 +311,268 @@ def list_skills(
311 key = _normalize_name(s.name) or _normalize_name(s.path.name)
312 if key and key not in by_name:
313 by_name[key] = s
310 - return list(by_name.values())
314 +
315 + result = list(by_name.values())
316 +
317 + if enabled_only:
318 + result = filter_enabled_skills(result, agent=agent)
319 +
320 + return result
321 +
322 +
323 +def _get_activation_file(project_name: str | None, profile_name: str | None) -> str:
324 + """Get the activation file path for the given scope."""
325 + if project_name and profile_name:
326 + return files.deabsolute_path(
327 + projects.get_project_meta_folder(project_name, "agents", profile_name, "skills.json")
328 + )
329 + elif project_name:
330 + return files.deabsolute_path(
331 + projects.get_project_meta_folder(project_name, "skills.json")
332 + )
333 + elif profile_name:
334 + return files.get_abs_path(subagents.USER_AGENTS_DIR, profile_name, "skills.json")
335 + return files.get_abs_path(subagents.USER_DIR, "skills", "skills.json")
336 +
337 +
338 +def _load_activation_map(path: str) -> Dict[str, bool]:
339 + try:
340 + if not files.exists(path):
341 + return {}
342 + parsed = dirty_json.parse(files.read_file(path))
343 + if not isinstance(parsed, dict):
344 + return {}
345 + result: Dict[str, bool] = {}
346 + for key, value in parsed.items():
347 + result[str(key)] = bool(value)
348 + return result
349 + except Exception:
350 + return {}
351 +
352 +
353 +def _write_activation_map(path: str, data: Dict[str, bool]) -> None:
354 + content = dirty_json.stringify(data, indent=2)
355 + files.write_file(path, content)
356 +
357 +
358 +def _get_skill_roots_for_list(
359 + project_name: str | None = None,
360 + profile_name: str | None = None,
361 +) -> List[str]:
362 + """Get skill root directories for the specified scope."""
363 + roots: List[str] = []
364 +
365 + # global roots
366 + roots.append(files.get_abs_path("skills"))
367 + roots.append(files.get_abs_path("usr", "skills"))
368 +
369 + # project roots
370 + if project_name:
371 + if profile_name:
372 + roots.append(projects.get_project_meta_folder(project_name, "agents", profile_name, "skills"))
373 + roots.append(projects.get_project_meta_folder(project_name, "skills"))
374 +
375 + # agent roots
376 + if profile_name:
377 + roots.append(files.get_abs_path(subagents.USER_AGENTS_DIR, profile_name, "skills"))
378 + roots.append(files.get_abs_path(subagents.DEFAULT_AGENTS_DIR, profile_name, "skills"))
379 +
380 + # dedupe and filter existing
381 + seen: set[str] = set()
382 + result: List[str] = []
383 + for root in roots:
384 + if root in seen or not os.path.isdir(root):
385 + continue
386 + seen.add(root)
387 + result.append(root)
388 + return result
389 +
390 +
391 +def _get_scope_info(root: str, project_name: str | None, profile_name: str | None) -> Dict[str, str]:
392 + """Determine scope metadata for a skill root."""
393 + # determine origin
394 + origin = "default"
395 + if files.is_in_base_dir(root):
396 + rel = files.deabsolute_path(root)
397 + if rel.startswith("usr/") or rel.startswith("projects/"):
398 + origin = "user"
399 + if rel.startswith("projects/"):
400 + origin = "project"
401 +
402 + # determine scope
403 + scope = "global"
404 + scope_name = "global"
405 +
406 + if project_name and profile_name and ".a0proj" in root and "agents" in root:
407 + scope = "project_agent"
408 + scope_name = f"{project_name}:{profile_name}"
409 + elif project_name and ".a0proj" in root:
410 + scope = "project"
411 + scope_name = project_name
412 + elif profile_name and f"agents/{profile_name}" in root:
413 + scope = "agent"
414 + scope_name = profile_name
415 +
416 + return {
417 + "scope": scope,
418 + "scope_name": scope_name,
419 + "origin": origin,
420 + }
421 +
422 +
423 +def get_skills_list(
424 + project_name: str | None = None,
425 + profile_name: str | None = None,
426 +) -> List[Dict[str, Any]]:
427 + """Get list of all skills with activation status."""
428 + roots = _get_skill_roots_for_list(project_name, profile_name)
429 + activation_file = _get_activation_file(project_name, profile_name)
430 + activation_map = _load_activation_map(activation_file)
431 +
432 + entries: List[Dict[str, Any]] = []
433 + for root in roots:
434 + scope_info = _get_scope_info(root, project_name, profile_name)
435 +
436 + for skill_md in discover_skill_md_files(Path(root)):
437 + skill = skill_from_markdown(skill_md, include_content=False)
438 + if not skill:
439 + continue
440 +
441 + # generate skill_id
442 + rel_path = os.path.relpath(str(skill.path), root).replace("\\", "/")
443 + skill_id = f"{root}:{rel_path}"
444 + enabled = activation_map.get(skill_id, True)
445 +
446 + entries.append(
447 + {
448 + "skill_id": skill_id,
449 + "name": skill.name,
450 + "description": skill.description,
451 + "location": files.normalize_a0_path(str(skill.path)),
452 + "root": files.normalize_a0_path(root),
453 + "scope": scope_info["scope"],
454 + "scope_name": scope_info["scope_name"],
455 + "origin": scope_info["origin"],
456 + "enabled": bool(enabled),
457 + }
458 + )
459 + return entries
460 +
461 +
462 +def set_skill_activation(
463 + skill_id: str,
464 + enabled: bool,
465 + project_name: str | None = None,
466 + profile_name: str | None = None,
467 +) -> None:
468 + """Toggle skill activation."""
469 + if not skill_id or ":" not in skill_id:
470 + raise ValueError("Invalid skill_id")
471 +
472 + root, _ = skill_id.split(":", 1)
473 + allowed_roots = _get_skill_roots_for_list(project_name, profile_name)
474 +
475 + if root not in allowed_roots:
476 + raise ValueError("Skill root not in current scope")
477 +
478 + activation_file = _get_activation_file(project_name, profile_name)
479 + activation_map = _load_activation_map(activation_file)
480 +
481 + if enabled:
482 + activation_map.pop(skill_id, None)
483 + else:
484 + activation_map[skill_id] = False
485 +
486 + _write_activation_map(activation_file, activation_map)
487 +
488 +
489 +def delete_skill(
490 + skill_id: str,
491 + project_name: str | None = None,
492 + profile_name: str | None = None,
493 +) -> None:
494 + """Delete a skill directory."""
495 + if not skill_id or ":" not in skill_id:
496 + raise ValueError("Invalid skill_id")
497 +
498 + root, rel_path = skill_id.split(":", 1)
499 + if not rel_path or rel_path in ("", "."):
500 + raise ValueError("Cannot delete root directory")
501 +
502 + allowed_roots = _get_skill_roots_for_list(project_name, profile_name)
503 + if root not in allowed_roots:
504 + raise ValueError("Skill root not in current scope")
505 +
506 + # construct and validate path (prevent directory traversal)
507 + root_abs = os.path.abspath(root)
508 + skill_path = os.path.abspath(os.path.join(root, rel_path))
509 +
510 + # security check: ensure skill_path is within root
511 + if not skill_path.startswith(root_abs + os.sep) and skill_path != root_abs:
512 + raise ValueError("Invalid path: directory traversal detected")
513 +
514 + if not os.path.isdir(skill_path):
515 + raise FileNotFoundError("Skill directory not found")
516 +
517 + # delete directory
518 + files.delete_dir(skill_path)
519 +
520 + # clean up activation map
521 + activation_file = _get_activation_file(project_name, profile_name)
522 + activation_map = _load_activation_map(activation_file)
523 + activation_map.pop(skill_id, None)
524 + _write_activation_map(activation_file, activation_map)
525 +
526 +
527 +def filter_enabled_skills(skills: List[Skill], agent: Agent|None=None) -> List[Skill]:
528 + """Filter skills based on activation status."""
529 + if not skills:
530 + return skills
531 +
532 + roots = get_skill_roots(agent)
533 + if not roots:
534 + return skills
535 +
536 + # sort by path length (longest first) for proper matching
537 + roots.sort(key=len, reverse=True)
538 +
539 + # cache activation files to avoid repeated reads
540 + activation_cache: Dict[str, Dict[str, bool]] = {}
541 +
542 + enabled_skills: List[Skill] = []
543 + for skill in skills:
544 + # find matching root
545 + skill_root = None
546 + for root in roots:
547 + try:
548 + Path(str(skill.path)).relative_to(Path(root))
549 + skill_root = root
550 + break
551 + except Exception:
552 + continue
553 +
554 + if not skill_root:
555 + # skill not in any known root, include by default
556 + enabled_skills.append(skill)
557 + continue
558 +
559 + # determine which activation file to use for this root
560 + # note: we use global scope here since agent context doesn't map cleanly to project/profile
561 + activation_file = _get_activation_file(None, None)
562 +
563 + if activation_file not in activation_cache:
564 + activation_cache[activation_file] = _load_activation_map(activation_file)
565 +
566 + activation_map = activation_cache[activation_file]
567 +
568 + # check activation
569 + rel_path = os.path.relpath(str(skill.path), skill_root).replace("\\", "/")
570 + skill_id = f"{skill_root}:{rel_path}"
571 +
572 + if activation_map.get(skill_id, True):
573 + enabled_skills.append(skill)
574 +
575 + return enabled_skills
576
577
578 def find_skill(
@@ -415,15 +680,3 @@ def validate_skill_md(skill_md_path: Path) -> List[str]:
680 return ["Unable to parse SKILL.md frontmatter"]
681 return validate_skill(skill)
682
418 -
419 -def safe_path_within_dir(base_dir: Path, rel_path: str) -> Path:
420 - """
421 - Resolve rel_path inside base_dir, preventing directory traversal.
422 - """
423 - base = base_dir.resolve()
424 - candidate = (base / rel_path).resolve()
425 - if os.path.commonpath([str(candidate), str(base)]) != str(base):
426 - raise ValueError("Path escapes skill directory")
427 - return candidate
428 -
429 -
python/tools/skills_tool.py
+2 -1
@@ -58,8 +58,9 @@ class SkillsTool(Tool):
58
59 def _list(self) -> str:
60 skills = skills_helper.list_skills(
61 - include_content=False,
61 agent=self.agent,
62 + include_content=False,
63 + enabled_only=True,
64 )
65 if not skills:
66 return "No skills found."
webui/components/settings/skills/list.html new
+186
@@ -0,0 +1,186 @@
1 +<html>
2 +<head>
3 + <title>List Skills</title>
4 + <script type="module">
5 + import { store } from "/components/settings/skills/skills-list-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.skillsListStore">
11 + <div x-init="$store.skillsListStore.init()" x-destroy="$store.skillsListStore.onClose()">
12 +
13 + <div class="section-title">List Skills</div>
14 + <div class="section-description">
15 + Browse skills across global and project scopes. Duplicates are shown individually.
16 + </div>
17 +
18 + <div class="skills-list-controls">
19 + <div class="field">
20 + <div class="field-label">
21 + <div class="field-title">Project scope</div>
22 + <div class="field-description">
23 + Includes global skills plus the selected project.
24 + </div>
25 + </div>
26 + <div class="field-control">
27 + <select x-model="$store.skillsListStore.projectName"
28 + @change="$store.skillsListStore.loadSkills()">
29 + <option value="">No project</option>
30 + <template x-for="project in $store.skillsListStore.projects" :key="project.name">
31 + <option :value="project.name" x-text="project.title || project.name"></option>
32 + </template>
33 + </select>
34 + </div>
35 + </div>
36 + <div class="skills-list-actions">
37 + <button type="button" class="button confirm" title="Refresh"
38 + @click="$store.skillsListStore.loadSkills()"
39 + :disabled="$store.skillsListStore.loading">
40 + <span class="icon material-symbols-outlined">refresh</span> Refresh
41 + </button>
42 + </div>
43 + </div>
44 +
45 + <div x-show="$store.skillsListStore.loading" class="loading">
46 + <span>Loading skills...</span>
47 + </div>
48 +
49 + <div x-show="$store.skillsListStore.error" class="error">
50 + <span x-text="$store.skillsListStore.error"></span>
51 + </div>
52 +
53 + <template
54 + x-if="!$store.skillsListStore.loading && !$store.skillsListStore.error && $store.skillsListStore.skills.length === 0">
55 + <div class="skills-empty">No skills found.</div>
56 + </template>
57 +
58 + <div class="skills-list">
59 + <template x-for="skill in $store.skillsListStore.skills" :key="skill.skill_id">
60 + <div class="skill-card">
61 + <div class="skill-header">
62 + <div class="skill-title" x-text="skill.name || '(unnamed skill)'"></div>
63 + <div class="skill-actions">
64 + <template x-if="skill.enabled">
65 + <button type="button" class="button cancel" title="Disable" style="width:9em"
66 + @click="$store.skillsListStore.toggleSkill(skill, false)">
67 + <span class="icon material-symbols-outlined">close</span> Disable
68 + </button>
69 + </template>
70 + <template x-if="!skill.enabled">
71 + <button type="button" class="button confirm" title="Enable" style="width:9em"
72 + @click="$store.skillsListStore.toggleSkill(skill, true)">
73 + <span class="icon material-symbols-outlined">play_arrow</span> Enable
74 + </button>
75 + </template>
76 + <button type="button" class="button confirm" title="Open in browser"
77 + @click="$store.skillsListStore.openSkill(skill)">
78 + <span class="icon material-symbols-outlined">folder_open</span> Open
79 + </button>
80 + <button type="button" class="button cancel icon-button" title="Delete"
81 + @click="$confirmClick($event, () => $store.skillsListStore.deleteSkill(skill))">
82 + <span class="icon material-symbols-outlined">delete</span>
83 + </button>
84 + </div>
85 + </div>
86 + <div class="skill-description" x-text="skill.description || 'No description provided.'"></div>
87 + <div class="skill-location">
88 + <span>Location:</span>
89 + <code x-text="skill.location"></code>
90 + </div>
91 + </div>
92 + </template>
93 + </div>
94 +
95 + </div>
96 + </template>
97 + </div>
98 +
99 + <style>
100 + .skills-list-controls {
101 + display: flex;
102 + align-items: flex-end;
103 + justify-content: space-between;
104 + gap: 1rem;
105 + flex-wrap: wrap;
106 + }
107 +
108 + .skills-list-actions {
109 + margin-left: auto;
110 + }
111 +
112 + .skills-list {
113 + margin-top: 1rem;
114 + }
115 +
116 + .skill-card {
117 + border: 1px solid var(--color-border);
118 + border-radius: 4px;
119 + padding: 0.75rem;
120 + margin-top: 0.75rem;
121 + background: var(--color-bg-primary);
122 + }
123 +
124 + .skill-header {
125 + display: flex;
126 + align-items: flex-start;
127 + justify-content: space-between;
128 + gap: 1rem;
129 + flex-wrap: wrap;
130 + }
131 +
132 + .skill-title {
133 + font-weight: 600;
134 + font-size: 1rem;
135 + }
136 +
137 + .skill-actions {
138 + display: flex;
139 + align-items: center;
140 + gap: 0.5rem;
141 + padding-bottom: var(--spacing-sm);
142 + flex-wrap: wrap;
143 + }
144 +
145 + .skill-description {
146 + margin-top: 0.35rem;
147 + color: var(--color-text-secondary);
148 + }
149 +
150 + .skill-location {
151 + margin-top: 0.35rem;
152 + font-size: 0.85rem;
153 + color: var(--color-text-secondary);
154 + display: flex;
155 + gap: 0.5rem;
156 + align-items: center;
157 + flex-wrap: wrap;
158 + }
159 +
160 + .skills-empty {
161 + margin-top: 1rem;
162 + padding: 0.75rem;
163 + border: 1px dashed var(--color-border);
164 + border-radius: 4px;
165 + text-align: center;
166 + color: var(--color-text-secondary);
167 + }
168 +
169 + .loading {
170 + width: 100%;
171 + text-align: center;
172 + margin-top: 1rem;
173 + margin-bottom: 1rem;
174 + color: var(--color-secondary);
175 + }
176 +
177 + .error {
178 + color: var(--color-error);
179 + margin: 0.5rem 0;
180 + padding: 0.5rem;
181 + background: var(--color-error-bg);
182 + border-radius: 4px;
183 + }
184 + </style>
185 +</body>
186 +</html>
webui/components/settings/skills/skills-list-store.js new
+157
@@ -0,0 +1,157 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
3 +import { store as settingsStore } from "/components/settings/settings-store.js";
4 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
5 +
6 +const fetchApi = globalThis.fetchApi;
7 +
8 +const model = {
9 + loading: false,
10 + error: "",
11 + skills: [],
12 + projects: [],
13 + projectName: "",
14 + profileName: "",
15 +
16 + async init() {
17 + this.resetState();
18 + await this.loadProjects();
19 + this.setDefaultProject();
20 + await this.loadSkills();
21 + },
22 +
23 + resetState() {
24 + this.loading = false;
25 + this.error = "";
26 + this.skills = [];
27 + this.projects = [];
28 + this.projectName = "";
29 + this.profileName = "";
30 + },
31 +
32 + onClose() {
33 + this.resetState();
34 + },
35 +
36 + setDefaultProject() {
37 + if (this.projectName) return;
38 + const active = chatsStore?.selectedContext?.project?.name;
39 + if (active) {
40 + this.projectName = active;
41 + }
42 + },
43 +
44 + refreshProfileName() {
45 + this.profileName = settingsStore?.settings?.agent_profile || "";
46 + },
47 +
48 + async loadProjects() {
49 + try {
50 + const response = await fetchApi("/projects", {
51 + method: "POST",
52 + headers: { "Content-Type": "application/json" },
53 + body: JSON.stringify({ action: "list" }),
54 + });
55 + const data = await response.json().catch(() => ({}));
56 + this.projects = data.ok ? (data.data || []) : [];
57 + } catch (e) {
58 + console.error("Failed to load projects:", e);
59 + this.projects = [];
60 + }
61 + },
62 +
63 + async loadSkills() {
64 + this.refreshProfileName();
65 + try {
66 + this.loading = true;
67 + this.error = "";
68 + const response = await fetchApi("/skills", {
69 + method: "POST",
70 + headers: { "Content-Type": "application/json" },
71 + body: JSON.stringify({
72 + action: "list",
73 + project_name: this.projectName || null,
74 + profile_name: this.profileName || null,
75 + }),
76 + });
77 + const result = await response.json().catch(() => ({}));
78 + if (!result.ok) {
79 + this.error = result.error || "Failed to load skills";
80 + this.skills = [];
81 + return;
82 + }
83 + this.skills = Array.isArray(result.data) ? result.data : [];
84 + } catch (e) {
85 + this.error = e?.message || "Failed to load skills";
86 + this.skills = [];
87 + } finally {
88 + this.loading = false;
89 + }
90 + },
91 +
92 + async toggleSkill(skill, enabled) {
93 + if (!skill) return;
94 + const previous = skill.enabled;
95 + skill.enabled = enabled;
96 + try {
97 + const response = await fetchApi("/skills", {
98 + method: "POST",
99 + headers: { "Content-Type": "application/json" },
100 + body: JSON.stringify({
101 + action: "toggle",
102 + skill_id: skill.skill_id,
103 + enabled,
104 + project_name: this.projectName || null,
105 + profile_name: this.profileName || null,
106 + }),
107 + });
108 + const result = await response.json().catch(() => ({}));
109 + if (!result.ok) {
110 + throw new Error(result.error || "Toggle failed");
111 + }
112 + } catch (e) {
113 + skill.enabled = previous;
114 + const msg = e?.message || "Toggle failed";
115 + if (window.toastFrontendError) {
116 + window.toastFrontendError(msg, "Skills");
117 + }
118 + }
119 + },
120 +
121 + async deleteSkill(skill) {
122 + if (!skill) return;
123 + try {
124 + const response = await fetchApi("/skills", {
125 + method: "POST",
126 + headers: { "Content-Type": "application/json" },
127 + body: JSON.stringify({
128 + action: "delete",
129 + skill_id: skill.skill_id,
130 + project_name: this.projectName || null,
131 + profile_name: this.profileName || null,
132 + }),
133 + });
134 + const result = await response.json().catch(() => ({}));
135 + if (!result.ok) {
136 + throw new Error(result.error || "Delete failed");
137 + }
138 + if (window.toastFrontendSuccess) {
139 + window.toastFrontendSuccess("Skill deleted", "Skills");
140 + }
141 + await this.loadSkills();
142 + } catch (e) {
143 + const msg = e?.message || "Delete failed";
144 + if (window.toastFrontendError) {
145 + window.toastFrontendError(msg, "Skills");
146 + }
147 + }
148 + },
149 +
150 + async openSkill(skill) {
151 + if (!skill?.location) return;
152 + await fileBrowserStore.open(skill.location);
153 + },
154 +};
155 +
156 +const store = createStore("skillsListStore", model);
157 +export { store };
webui/components/settings/skills/skills-settings.html
+10
@@ -9,6 +9,12 @@
9 <div>
10 <nav>
11 <ul>
12 + <li>
13 + <a href="#section-skills-list">
14 + <img src="/public/skills.svg" alt="Skills" />
15 + <span>List Skills</span>
16 + </a>
17 + </li>
18 <li>
19 <a href="#section-skills-import">
20 <img src="/public/skills.svg" alt="Skills" />
@@ -18,6 +24,10 @@
24 </ul>
25 </nav>
26
27 + <div id="section-skills-list" class="section">
28 + <x-component path="settings/skills/list.html"></x-component>
29 + </div>
30 +
31 <div id="section-skills-import" class="section">
32 <x-component path="settings/skills/import.html"></x-component>
33 </div>