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>