polish skills import, bugfixes
frdel committed
Feb 6, 2026 at 13:41 UTC
fd06f51ee01844be3b1f57312f1a3208897f4f27
22 files changed
+394
-167
agents/default/agent.json
+1
-1
@@ -1,5 +1,5 @@
1
{
2
- "title": "Default prompts",
2
+ "title": "Default",
3
"description": "Default prompt file templates. Should be inherited and overriden by specialized prompt profiles.",
4
"context": ""
5
}
python/api/agents.py
new
+23
@@ -0,0 +1,23 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request
2
+from python.helpers import subagents
3
+
4
+
5
+class Agents(ApiHandler):
6
+ async def process(self, input: Input, request: Request) -> Output:
7
+ action = input.get("action", "")
8
+
9
+ try:
10
+ if action == "list":
11
+ data = subagents.get_all_agents_list()
12
+ else:
13
+ raise Exception("Invalid action")
14
+
15
+ return {
16
+ "ok": True,
17
+ "data": data,
18
+ }
19
+ except Exception as e:
20
+ return {
21
+ "ok": False,
22
+ "error": str(e),
23
+ }
python/api/projects.py
+10
@@ -14,6 +14,8 @@ class Projects(ApiHandler):
14
try:
15
if action == "list":
16
data = self.get_active_projects_list()
17
+ elif action == "list_options":
18
+ data = self.get_active_projects_options()
19
elif action == "load":
20
data = self.load_project(input.get("name", None))
21
elif action == "create":
@@ -46,6 +48,14 @@ class Projects(ApiHandler):
48
def get_active_projects_list(self):
49
return projects.get_active_projects_list()
50
51
+ def get_active_projects_options(self):
52
+ items = projects.get_active_projects_list() or []
53
+ return [
54
+ {"key": p.get("name", ""), "label": p.get("title", "") or p.get("name", "")}
55
+ for p in items
56
+ if p.get("name")
57
+ ]
58
+
59
def create_project(self, project: dict|None):
60
if project is None:
61
raise Exception("Project data is required")
python/api/skills.py
+21
-1
@@ -1,5 +1,5 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
-from python.helpers import skills, projects, files
2
+from python.helpers import runtime, skills, projects, files
3
4
5
class Skills(ApiHandler):
@@ -30,10 +30,29 @@ class Skills(ApiHandler):
30
# filter by project
31
if project_name := (input.get("project_name") or "").strip() or None:
32
project_folder = projects.get_project_folder(project_name)
33
+ if runtime.is_development():
34
+ project_folder = files.normalize_a0_path(project_folder)
35
skill_list = [
36
s for s in skill_list if files.is_in_dir(str(s.path), project_folder)
37
]
38
39
+ # filter by agent profile
40
+ if agent_profile := (input.get("agent_profile") or "").strip() or None:
41
+ roots: list[str] = [
42
+ files.get_abs_path("agents", agent_profile, "skills"),
43
+ files.get_abs_path("usr", "agents", agent_profile, "skills"),
44
+ ]
45
+ if project_name:
46
+ roots.append(
47
+ projects.get_project_meta_folder(project_name, "agents", agent_profile, "skills")
48
+ )
49
+
50
+ skill_list = [
51
+ s
52
+ for s in skill_list
53
+ if any(files.is_in_dir(str(s.path), r) for r in roots)
54
+ ]
55
+
56
result = []
57
for skill in skill_list:
58
result.append({
@@ -41,6 +60,7 @@ class Skills(ApiHandler):
60
"description": skill.description,
61
"path": str(skill.path),
62
})
63
+ result.sort(key=lambda x: (x["name"], x["path"]))
64
return result
65
66
def delete_skill(self, input: Input):
python/api/skills_import.py
+2
-9
@@ -31,20 +31,13 @@ class SkillsImport(ApiHandler):
31
return {"success": False, "error": "No context id provided"}
32
_context = self.use_context(ctxid)
33
34
- dest = (request.form.get("dest", "custom") or "custom").strip().lower()
35
- if dest not in ("custom", "project"):
36
- dest = "custom"
37
-
34
conflict = (request.form.get("conflict", "skip") or "skip").strip().lower()
35
if conflict not in ("skip", "overwrite", "rename"):
36
conflict = "skip"
37
38
namespace = (request.form.get("namespace", "") or "").strip() or None
39
project_name = (request.form.get("project_name", "") or "").strip() or None
44
-
45
- # If dest is "project", project_name is required
46
- if dest == "project" and not project_name:
47
- return {"success": False, "error": "project_name is required when dest is 'project'"}
40
+ agent_profile = (request.form.get("agent_profile", "") or "").strip() or None
41
42
# Save upload to a temp file so we can pass a filesystem path to the importer
43
tmp_dir = Path(files.get_abs_path("tmp", "uploads"))
@@ -60,11 +53,11 @@ class SkillsImport(ApiHandler):
53
try:
54
result = import_skills(
55
str(tmp_path),
63
- dest_subdir=dest, # type: ignore[arg-type]
56
namespace=namespace,
57
conflict=conflict, # type: ignore[arg-type]
58
dry_run=False, # Actual import, not preview
59
project_name=project_name,
60
+ agent_profile=agent_profile,
61
)
62
63
imported = [files.deabsolute_path(str(p)) for p in result.imported]
python/api/skills_import_preview.py
+2
-9
@@ -31,20 +31,13 @@ class SkillsImportPreview(ApiHandler):
31
return {"success": False, "error": "No context id provided"}
32
_context = self.use_context(ctxid)
33
34
- dest = (request.form.get("dest", "custom") or "custom").strip().lower()
35
- if dest not in ("custom", "project"):
36
- dest = "custom"
37
-
34
conflict = (request.form.get("conflict", "skip") or "skip").strip().lower()
35
if conflict not in ("skip", "overwrite", "rename"):
36
conflict = "skip"
37
38
namespace = (request.form.get("namespace", "") or "").strip() or None
39
project_name = (request.form.get("project_name", "") or "").strip() or None
44
-
45
- # If dest is "project", project_name is required
46
- if dest == "project" and not project_name:
47
- return {"success": False, "error": "project_name is required when dest is 'project'"}
40
+ agent_profile = (request.form.get("agent_profile", "") or "").strip() or None
41
42
# Save upload to a temp file so we can pass a filesystem path to the importer
43
tmp_dir = Path(files.get_abs_path("tmp", "uploads"))
@@ -60,11 +53,11 @@ class SkillsImportPreview(ApiHandler):
53
try:
54
result = import_skills(
55
str(tmp_path),
63
- dest_subdir=dest, # type: ignore[arg-type]
56
namespace=namespace,
57
conflict=conflict, # type: ignore[arg-type]
58
dry_run=True,
59
project_name=project_name,
60
+ agent_profile=agent_profile,
61
)
62
63
imported = [files.deabsolute_path(str(p)) for p in result.imported]
python/extensions/agent_init/_15_load_profile_settings.py
+10
-8
@@ -10,8 +10,7 @@ class LoadProfileSettings(Extension):
10
if not self.agent or not self.agent.config.profile:
11
return
12
13
- config_files = subagents.get_paths(self.agent, "settings.json", include_default=False)
14
-
13
+ config_files = subagents.get_paths(self.agent, "settings.json", include_default=False, include_user=False)
14
settings_override = {}
15
for settings_path in config_files:
16
if files.exists(settings_path):
@@ -34,14 +33,17 @@ class LoadProfileSettings(Extension):
33
)
34
35
if settings_override:
37
- # Preserve the original memory_subdir unless it's explicitly overridden
38
- current_memory_subdir = self.agent.config.memory_subdir
36
+ current_config = self.agent.config
37
new_config = initialize_agent(override_settings=settings_override)
40
- if (
41
- "agent_memory_subdir" not in settings_override
42
- and current_memory_subdir != "default"
38
+
39
+ for override_key, config_attr in (
40
+ ("agent_profile", "profile"),
41
+ ("agent_memory_subdir", "memory_subdir"),
42
+ ("mcp_servers", "mcp_servers"),
43
+ ("browser_http_headers", "browser_http_headers"),
44
):
44
- new_config.memory_subdir = current_memory_subdir
45
+ if override_key not in settings_override:
46
+ setattr(new_config, config_attr, getattr(current_config, config_attr))
47
self.agent.config = new_config
48
# self.agent.context.log.log(
49
# type="info",
python/extensions/banners/_30_system_resources.py
+12
-6
@@ -20,7 +20,7 @@ class SystemResourcesCheck(Extension):
20
try:
21
vm = psutil.virtual_memory()
22
ram_percent = vm.percent
23
- ram_used_gb = vm.used / (1024 ** 3)
23
+ ram_used_gb = (vm.total - vm.available) / (1024 ** 3)
24
ram_total_gb = vm.total / (1024 ** 3)
25
except Exception:
26
ram_percent = None
@@ -70,23 +70,29 @@ class SystemResourcesCheck(Extension):
70
"title": "System Resources",
71
"html": (
72
"<div style=\"display:flex;flex-direction:column;gap:14px;padding-top:6px;\">"
73
- "<div style=\"display:grid;grid-template-columns:140px 1fr;row-gap:12px;column-gap:12px;align-items:center;\">"
74
- "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
73
+ "<div style=\"display:flex;flex-direction:column;gap:12px;\">"
74
+ "<div style=\"display:flex;flex-wrap:wrap;column-gap:12px;row-gap:10px;align-items:center;\">"
75
+ "<div style=\"flex:0 1 140px;min-width:110px;display:flex;flex-direction:column;gap:6px;\">"
76
"<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">CPU</div>"
77
f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{cpu_value}</div>"
78
"</div>"
79
f"{cpu_bar}"
79
- "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
80
+ "</div>"
81
+ "<div style=\"display:flex;flex-wrap:wrap;column-gap:12px;row-gap:10px;align-items:center;\">"
82
+ "<div style=\"flex:0 1 140px;min-width:110px;display:flex;flex-direction:column;gap:6px;\">"
83
"<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">RAM</div>"
84
f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{ram_value}</div>"
85
"</div>"
86
f"{ram_bar}"
84
- "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
87
+ "</div>"
88
+ "<div style=\"display:flex;flex-wrap:wrap;column-gap:12px;row-gap:10px;align-items:center;\">"
89
+ "<div style=\"flex:0 1 140px;min-width:110px;display:flex;flex-direction:column;gap:6px;\">"
90
"<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">Disk</div>"
91
f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{disk_value}</div>"
92
"</div>"
93
f"{disk_bar}"
94
"</div>"
95
+ "</div>"
96
"<div style=\"height:1px;background:rgba(255,255,255,.08);\"></div>"
97
"<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:10px 14px;\">"
98
f"<div><div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;margin-bottom:6px;\">Load (1/5/15)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85;font-variant-numeric:tabular-nums\">{load_value}</div></div>"
@@ -111,7 +117,7 @@ class SystemResourcesCheck(Extension):
117
color = "#22c55e"
118
119
return (
114
- "<div style=\"flex:1;min-width:140px;max-width:260px;height:10px;border-radius:999px;"
120
+ "<div style=\"flex:1 1 220px;width:100%;max-width:260px;height:10px;border-radius:999px;"
121
"background:rgba(255,255,255,.10);overflow:hidden;box-shadow:inset 0 0 0 1px rgba(255,255,255,.08);\">"
122
f"<div style=\"height:100%;width:{p:.0f}%;background:{color};border-radius:999px;\"></div>"
123
"</div>"
python/helpers/skills.py
+3
-1
@@ -236,7 +236,7 @@ def skill_from_markdown(
236
fm, body, fm_errors = split_frontmatter(text)
237
if fm_errors:
238
return None
239
- skill_dir = skill_md_path.parent
239
+ skill_dir = Path(files.normalize_a0_path(str(skill_md_path.parent)))
240
241
name = str(fm.get("name") or fm.get("skill") or "").strip()
242
description = str(
@@ -325,6 +325,8 @@ def delete_skill(
325
"""Delete a skill directory."""
326
327
skill_path = files.get_abs_path(skill_path)
328
+ if runtime.is_development():
329
+ skill_path = files.fix_dev_path(skill_path)
330
331
allowed_roots = get_skill_roots()
332
for root in allowed_roots:
python/helpers/skills_import.py
+25
-12
@@ -14,7 +14,6 @@ from python.helpers.skills import discover_skill_md_files
14
15
16
ConflictPolicy = Literal["skip", "overwrite", "rename"]
17
-DestSubdir = Literal["custom", "project"]
17
18
# Project skills folder name (inside .a0proj)
19
PROJECT_SKILLS_DIR = "skills"
@@ -177,19 +176,39 @@ def get_project_skills_folder(project_name: str) -> Path:
176
return Path(get_project_meta_folder(project_name, PROJECT_SKILLS_DIR))
177
178
179
+def get_agent_profile_skills_folder(profile_name: str) -> Path:
180
+ return Path(files.get_abs_path("usr", "agents", profile_name, "skills"))
181
+
182
+
183
+def get_project_agent_profile_skills_folder(project_name: str, profile_name: str) -> Path:
184
+ from python.helpers.projects import get_project_meta_folder
185
+ return Path(get_project_meta_folder(project_name, "agents", profile_name, "skills"))
186
+
187
+
188
+def resolve_skills_destination_root(
189
+ project_name: Optional[str],
190
+ agent_profile: Optional[str],
191
+) -> Path:
192
+ if project_name and agent_profile:
193
+ return get_project_agent_profile_skills_folder(project_name, agent_profile)
194
+ if project_name:
195
+ return get_project_skills_folder(project_name)
196
+ if agent_profile:
197
+ return get_agent_profile_skills_folder(agent_profile)
198
+ return Path(files.get_abs_path("usr", "skills"))
199
+
200
+
201
def import_skills(
202
source_path: str,
203
*,
183
- dest_subdir: DestSubdir = "custom",
204
namespace: Optional[str] = None,
205
conflict: ConflictPolicy = "skip",
206
dry_run: bool = False,
207
project_name: Optional[str] = None,
208
+ agent_profile: Optional[str] = None,
209
) -> ImportResult:
210
"""
190
- Import external Skills into usr/skills/<dest_subdir>/<namespace>/...
191
-
192
- If dest_subdir is "project", imports into the project's .a0proj/skills/ folder.
211
+ Import external Skills into usr/skills/<namespace>/...
212
213
- source_path can be a directory or a .zip file
214
- Uses heuristics to detect the Skills root(s)
@@ -202,13 +221,7 @@ def import_skills(
221
if not src.exists():
222
raise FileNotFoundError(f"Source not found: {src}")
223
205
- # Determine destination root based on dest_subdir
206
- if dest_subdir == "project":
207
- if not project_name:
208
- raise ValueError("project_name is required when dest_subdir is 'project'")
209
- dest_root = get_project_skills_folder(project_name)
210
- else:
211
- dest_root = Path(files.get_abs_path("usr", "skills"))
224
+ dest_root = resolve_skills_destination_root(project_name, agent_profile)
225
dest_root.mkdir(parents=True, exist_ok=True)
226
227
extracted_root: Optional[Path] = None
python/helpers/subagents.py
+51
@@ -3,6 +3,7 @@ from typing import TypedDict, TYPE_CHECKING
3
from pydantic import BaseModel, model_validator
4
import json
5
from typing import Literal
6
+import os
7
8
GLOBAL_DIR = "."
9
USER_DIR = "usr"
@@ -20,6 +21,7 @@ class SubAgentListItem(BaseModel):
21
title: str = ""
22
description: str = ""
23
context: str = ""
24
+ path: str = ""
25
origin: list[Origin] = []
26
enabled: bool = True
27
@@ -81,6 +83,7 @@ def _get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentLis
83
agent_data = SubAgentListItem.model_validate_json(agent_json)
84
name = agent_data.name or subdir
85
agent_data.name = name
86
+ agent_data.path = files.get_abs_path(dir, subdir)
87
agent_data.origin = [origin]
88
result[name] = agent_data
89
except Exception:
@@ -213,10 +216,58 @@ def _merge_agent_list_items(
216
title=override.title or base.title,
217
description=override.description or base.description,
218
context=override.context or base.context,
219
+ path=override.path or base.path,
220
origin=_merge_origins(base.origin, override.origin),
221
)
222
223
224
+def get_agents_roots() -> list[str]:
225
+ project_agents = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/agents")
226
+ paths = [
227
+ files.get_abs_path(DEFAULT_AGENTS_DIR),
228
+ files.get_abs_path(USER_AGENTS_DIR),
229
+ *project_agents,
230
+ ]
231
+ unique: list[str] = []
232
+ seen = set()
233
+ for p in paths:
234
+ if not p:
235
+ continue
236
+ key = str(p)
237
+ if key in seen:
238
+ continue
239
+ seen.add(key)
240
+ if os.path.exists(p):
241
+ unique.append(p)
242
+ return unique
243
+
244
+
245
+def get_all_agents_list() -> list[dict[str, str]]:
246
+ def _origin_from_root(root: str) -> Origin:
247
+ rel = files.deabsolute_path(root).replace("\\", "/")
248
+ if rel.startswith("usr/projects/"):
249
+ return "project"
250
+ if rel.startswith("usr/agents"):
251
+ return "user"
252
+ return "default"
253
+
254
+ merged: dict[str, SubAgentListItem] = {}
255
+ for root in get_agents_roots():
256
+ origin = _origin_from_root(root)
257
+ items = _get_agents_list_from_dir(root, origin=origin)
258
+ for name, item in items.items():
259
+ if name in merged:
260
+ merged[name] = _merge_agent_list_items(merged[name], item)
261
+ else:
262
+ merged[name] = item
263
+
264
+ result: list[dict[str, str]] = []
265
+ for key in sorted(merged.keys()):
266
+ item = merged[key]
267
+ result.append({"key": key, "label": item.title or key})
268
+ return result
269
+
270
+
271
def _merge_origins(base: list[Origin], override: list[Origin]) -> list[Origin]:
272
return base + override
273
python/tools/call_subordinate.py
+1
-1
@@ -16,7 +16,7 @@ class Delegation(Tool):
16
config = initialize_agent()
17
18
# set subordinate prompt profile if provided, if not, keep original
19
- agent_profile = kwargs.get("profile")
19
+ agent_profile = kwargs.get("profile", kwargs.get("agent_profile", ""))
20
if agent_profile:
21
config.profile = agent_profile
22
webui/components/projects/project-edit-skills.html
+8
-12
@@ -7,16 +7,7 @@
7
</script>
8
</head>
9
<body>
10
- <div x-data="{
11
- openSkillsImport() {
12
- // Pre-configure the skills import for this project
13
- if ($store.skillsImportStore) {
14
- $store.skillsImportStore.dest = 'project';
15
- $store.skillsImportStore.projectName = $store.projects.selectedProject.name;
16
- }
17
- openModal('settings/skills/import.html');
18
- }
19
- }">
10
+ <div x-data>
11
<template x-if="$store.projects && $store.projects.selectedProject">
12
<div class="project-skills-section">
13
<p class="skills-description">
@@ -25,15 +16,20 @@
16
</p>
17
18
<div class="skills-actions">
28
- <button type="button" class="button" @click="openSkillsImport()">
19
+ <button type="button" class="button" @click="$store.projects.openSelectedProjectSkillsImport()">
20
+ <span class="icon material-symbols-outlined">upload</span>
21
Import Skills
22
</button>
23
+ <button type="button" class="button" @click="$store.projects.openSelectedProjectSkillsFolder()">
24
+ <span class="icon material-symbols-outlined">folder_open</span>
25
+ Open Folder
26
+ </button>
27
</div>
28
29
<div class="skills-info">
30
<p>
31
<strong>Project Skills Location:</strong><br>
36
- <code x-text="'usr/projects/' + $store.projects.selectedProject.name + '/.a0proj/skills/'"></code>
32
+ <code x-text="$store.projects.getSelectedProjectSkillsPath()"></code>
33
</p>
34
<p class="skills-note">
35
Use the global Settings > Skills tab for custom skills available to all projects.
webui/components/projects/projects-store.js
+22
@@ -4,6 +4,7 @@ import * as modals from "/js/modals.js";
4
import * as notifications from "/components/notifications/notification-store.js";
5
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6
import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
7
+import { store as skillsImportStore } from "/components/settings/skills/skills-import-store.js";
8
import * as shortcuts from "/js/shortcuts.js";
9
import { showConfirmDialog } from "/js/confirmDialog.js";
10
@@ -65,6 +66,27 @@ const model = {
66
return s;
67
},
68
69
+ getSelectedProjectSkillsPath() {
70
+ const projectName = this.selectedProject?.name;
71
+ if (!projectName) return "";
72
+ return `usr/projects/${projectName}/.a0proj/skills/`;
73
+ },
74
+
75
+ async openSelectedProjectSkillsImport() {
76
+ const projectName = this.selectedProject?.name;
77
+ if (!projectName) return;
78
+
79
+ skillsImportStore.projectKey = projectName;
80
+ skillsImportStore.agentProfileKey = "";
81
+ await modals.openModal("settings/skills/import.html");
82
+ },
83
+
84
+ async openSelectedProjectSkillsFolder() {
85
+ const path = this.getSelectedProjectSkillsPath();
86
+ if (!path) return;
87
+ await browserStore.open(path);
88
+ },
89
+
90
async openProjectsModal() {
91
await this.loadProjectsList();
92
await modals.openModal(listModal);
webui/components/settings/skills/import.html
+29
-15
@@ -25,21 +25,23 @@
25
26
<div class="options" x-show="$store.skillsImportStore.skillsFile">
27
<label class="policy-label">
28
- <span class="policy-label-text">Destination:</span>
29
- <select x-model="$store.skillsImportStore.dest" class="policy-dropdown"
28
+ <span class="policy-label-text">Limit to project:</span>
29
+ <select x-model="$store.skillsImportStore.projectKey" class="policy-dropdown"
30
@change="$store.skillsImportStore.previewImport()">
31
- <option value="custom">Custom</option>
32
- <option value="project">Project</option>
31
+ <option value="">All</option>
32
+ <template x-for="project in $store.skillsImportStore.projects" :key="project.key">
33
+ <option :value="project.key" x-text="project.label"></option>
34
+ </template>
35
</select>
36
</label>
37
36
- <label class="policy-label" x-show="$store.skillsImportStore.dest === 'project'">
37
- <span class="policy-label-text">Project:</span>
38
- <select x-model="$store.skillsImportStore.projectName" class="policy-dropdown"
38
+ <label class="policy-label">
39
+ <span class="policy-label-text">Limit to agent profile:</span>
40
+ <select x-model="$store.skillsImportStore.agentProfileKey" class="policy-dropdown"
41
@change="$store.skillsImportStore.previewImport()">
40
- <option value="">Select a project...</option>
41
- <template x-for="project in $store.skillsImportStore.projects" :key="project.name">
42
- <option :value="project.name" x-text="project.title || project.name"></option>
42
+ <option value="">All</option>
43
+ <template x-for="agentProfile in $store.skillsImportStore.agentProfiles" :key="agentProfile.key">
44
+ <option :value="agentProfile.key" x-text="agentProfile.label"></option>
45
</template>
46
</select>
47
</label>
@@ -80,7 +82,6 @@
82
<div x-show="$store.skillsImportStore.preview" class="preview">
83
<h4>Preview</h4>
84
<div class="preview-meta">
83
- <div>Destination: <code x-text="$store.skillsImportStore.preview?.destination"></code></div>
85
<div>Namespace: <code x-text="$store.skillsImportStore.preview?.namespace"></code></div>
86
<div>Would import: <span x-text="$store.skillsImportStore.preview?.imported_count || 0"></span></div>
87
<div>Would skip: <span x-text="$store.skillsImportStore.preview?.skipped_count || 0"></span></div>
@@ -135,16 +136,18 @@
136
}
137
138
.policy-label {
138
- display: flex;
139
+ display: grid;
140
+ grid-template-columns: minmax(10rem, 14rem) 1fr;
141
align-items: center;
140
- gap: 0.5rem;
142
+ column-gap: 0.75rem;
143
+ row-gap: 0.25rem;
144
margin: 0.5rem 0;
145
}
146
147
.policy-label-text {
148
font-weight: 600;
146
- white-space: nowrap;
147
- width: 9rem;
149
+ white-space: normal;
150
+ width: auto;
151
}
152
153
.policy-dropdown, .text-input {
@@ -214,6 +217,17 @@
217
font-size: 0.9rem;
218
color: var(--color-text-secondary);
219
}
220
+
221
+ @media (max-width: 700px) {
222
+ .policy-label {
223
+ grid-template-columns: 1fr;
224
+ }
225
+
226
+ .policy-label-text {
227
+ width: auto;
228
+ white-space: normal;
229
+ }
230
+ }
231
</style>
232
</body>
233
</html>
webui/components/settings/skills/list.html
+106
-37
@@ -15,31 +15,38 @@
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">Filter by project</div>
22
- <!-- <div class="field-description">
23
- Includes global skills plus the selected project.
24
- </div> -->
25
- </div>
26
- <div class="field-control">
18
+ <div class="skills-toolbar">
19
+ <div class="skills-toolbar-row">
20
+ <label class="skills-toolbar-item">
21
+ <span class="skills-toolbar-label">Project</span>
22
<select x-model="$store.skillsListStore.projectName"
23
@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>
24
+ <option value="">All</option>
25
+ <template x-for="project in $store.skillsListStore.projects" :key="project.key">
26
+ <option :value="project.key" x-text="project.label"></option>
27
+ </template>
28
+ </select>
29
+ </label>
30
+
31
+ <label class="skills-toolbar-item">
32
+ <span class="skills-toolbar-label">Agent profile</span>
33
+ <select x-model="$store.skillsListStore.agentProfileKey"
34
+ @change="$store.skillsListStore.loadSkills()">
35
+ <option value="">All</option>
36
+ <template x-for="agentProfile in $store.skillsListStore.agentProfiles" :key="agentProfile.key">
37
+ <option :value="agentProfile.key" x-text="agentProfile.label"></option>
38
</template>
39
</select>
40
+ </label>
41
+
42
+ <div class="skills-toolbar-actions">
43
+ <button type="button" class="button confirm" title="Refresh"
44
+ @click="$store.skillsListStore.loadSkills()"
45
+ :disabled="$store.skillsListStore.loading">
46
+ <span class="icon material-symbols-outlined">refresh</span> Refresh
47
+ </button>
48
</div>
49
</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>
50
</div>
51
52
<div x-show="$store.skillsListStore.loading" class="loading">
@@ -59,9 +66,12 @@
66
<template x-for="skill in $store.skillsListStore.skills" :key="skill.path">
67
<div class="skill-card">
68
<div class="skill-header">
62
- <div class="skill-title" x-text="skill.name || '(unnamed skill)'"></div>
69
+ <div class="skill-heading">
70
+ <div class="skill-title" x-text="skill.name || '(unnamed skill)'"></div>
71
+ <code class="skill-path" x-text="skill.path"></code>
72
+ </div>
73
<div class="skill-actions">
64
- <button type="button" class="button confirm" title="Open in browser"
74
+ <button type="button" class="button" title="Open in browser"
75
@click="$store.skillsListStore.openSkill(skill)">
76
<span class="icon material-symbols-outlined">folder_open</span> Open
77
</button>
@@ -72,10 +82,6 @@
82
</div>
83
</div>
84
<div class="skill-description" x-text="skill.description || 'No description provided.'"></div>
75
- <div class="skill-location">
76
- <span>Location:</span>
77
- <code x-text="skill.path"></code>
78
- </div>
85
</div>
86
</template>
87
</div>
@@ -85,16 +91,63 @@
91
</div>
92
93
<style>
88
- .skills-list-controls {
94
+ .skills-toolbar {
95
display: flex;
90
- align-items: flex-end;
91
- justify-content: space-between;
92
- gap: 1rem;
96
+ flex-direction: column;
97
+ gap: 0.35rem;
98
+ margin-top: 0.5rem;
99
+ }
100
+
101
+ .skills-toolbar-row {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 0.75rem;
105
flex-wrap: wrap;
106
}
107
96
- .skills-list-actions {
108
+ .skills-toolbar-item {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 0.5rem;
112
+ flex: 1 1 18rem;
113
+ min-width: 12rem;
114
+ margin: 0;
115
+ }
116
+
117
+ .skills-toolbar-label {
118
+ font-weight: 600;
119
+ color: var(--color-text-secondary);
120
+ white-space: nowrap;
121
+ }
122
+
123
+ .skills-toolbar-item select {
124
+ flex: 1 1 auto;
125
+ min-width: 0;
126
+ }
127
+
128
+ .skills-toolbar-actions {
129
margin-left: auto;
130
+ flex: 0 0 auto;
131
+ }
132
+
133
+ @media (max-width: 640px) {
134
+ .skills-toolbar-row {
135
+ gap: 0.5rem;
136
+ }
137
+
138
+ .skills-toolbar-item {
139
+ flex-basis: 100%;
140
+ min-width: 0;
141
+ }
142
+
143
+ .skills-toolbar-actions {
144
+ width: 100%;
145
+ margin-left: 0;
146
+ }
147
+
148
+ .skills-toolbar-actions .button {
149
+ width: 100%;
150
+ }
151
}
152
153
.skills-list {
@@ -114,12 +167,22 @@
167
align-items: flex-start;
168
justify-content: space-between;
169
gap: 1rem;
117
- flex-wrap: wrap;
170
+ flex-wrap: nowrap;
171
+ min-width: 0;
172
+ }
173
+
174
+ .skill-heading {
175
+ display: flex;
176
+ flex-direction: column;
177
+ min-width: 0;
178
+ flex: 1 1 auto;
179
}
180
181
.skill-title {
182
font-weight: 600;
183
font-size: 1rem;
184
+ min-width: 0;
185
+ flex: 1 1 auto;
186
}
187
188
.skill-actions {
@@ -128,21 +191,27 @@
191
gap: 0.5rem;
192
padding-bottom: var(--spacing-sm);
193
flex-wrap: wrap;
194
+ min-width: 0;
195
+ flex: 0 1 11.5rem;
196
+ max-width: 100%;
197
+ justify-content: flex-end;
198
}
199
200
.skill-description {
201
margin-top: 0.35rem;
202
color: var(--color-text-secondary);
203
+ font-size: var(--font-size-small);
204
}
205
138
- .skill-location {
206
+ .skill-path {
207
margin-top: 0.35rem;
208
font-size: 0.85rem;
141
- color: var(--color-text-secondary);
142
- display: flex;
143
- gap: 0.5rem;
144
- align-items: center;
145
- flex-wrap: wrap;
209
+ color: var(--color-text-muted);
210
+ min-width: 0;
211
+ max-width: 100%;
212
+ white-space: pre-wrap;
213
+ word-break: break-word;
214
+ overflow-wrap: anywhere;
215
}
216
217
.skills-empty {
webui/components/settings/skills/skills-import-store.js
+28
-28
@@ -1,6 +1,5 @@
1
import { createStore } from "/js/AlpineStore.js";
2
-
3
-const fetchApi = globalThis.fetchApi;
2
+import * as api from "/js/api.js";
3
4
function sanitizeNamespace(text) {
5
if (!text) return "";
@@ -16,11 +15,12 @@ const model = {
15
error: "",
16
17
skillsFile: null,
19
- dest: "custom", // custom|project
18
namespace: "",
19
conflict: "skip", // skip|overwrite|rename
22
- projectName: "", // selected project name when dest is "project"
23
- projects: [], // available projects list
20
+ projectKey: "", // selected project key, empty means All
21
+ agentProfileKey: "", // selected agent profile key, empty means All
22
+ projects: [], // available projects options [{key,label}]
23
+ agentProfiles: [], // available agent profile options [{key,label}]
24
25
preview: null,
26
result: null,
@@ -28,6 +28,7 @@ const model = {
28
init() {
29
this.resetState();
30
this.loadProjects();
31
+ this.loadAgentProfiles();
32
},
33
34
resetState() {
@@ -42,19 +43,14 @@ const model = {
43
this.resetState();
44
this.skillsFile = null;
45
this.namespace = "";
45
- this.dest = "custom";
46
this.conflict = "skip";
47
- this.projectName = "";
47
+ this.projectKey = "";
48
+ this.agentProfileKey = "";
49
},
50
51
async loadProjects() {
52
try {
52
- const response = await fetchApi("/projects", {
53
- method: "POST",
54
- headers: { "Content-Type": "application/json" },
55
- body: JSON.stringify({ action: "list" }),
56
- });
57
- const data = await response.json();
53
+ const data = await api.callJsonApi("/projects", { action: "list_options" });
54
this.projects = data.ok ? (data.data || []) : [];
55
} catch (e) {
56
console.error("Failed to load projects:", e);
@@ -62,6 +58,16 @@ const model = {
58
}
59
},
60
61
+ async loadAgentProfiles() {
62
+ try {
63
+ const data = await api.callJsonApi("/agents", { action: "list" });
64
+ this.agentProfiles = data.ok ? (data.data || []) : [];
65
+ } catch (e) {
66
+ console.error("Failed to load agent profiles:", e);
67
+ this.agentProfiles = [];
68
+ }
69
+ },
70
+
71
async handleFileUpload(event) {
72
const file = event.target.files[0];
73
if (!file) return;
@@ -86,11 +92,15 @@ const model = {
92
const formData = new FormData();
93
formData.append("skills_file", this.skillsFile);
94
formData.append("ctxid", globalThis.getContext ? globalThis.getContext() : "");
89
- formData.append("dest", this.dest);
95
formData.append("namespace", sanitizeNamespace(this.namespace));
96
formData.append("conflict", this.conflict);
92
- if (this.dest === "project" && this.projectName) {
93
- formData.append("project_name", this.projectName);
97
+
98
+ if (this.projectKey) {
99
+ formData.append("project_name", this.projectKey);
100
+ }
101
+
102
+ if (this.agentProfileKey) {
103
+ formData.append("agent_profile", this.agentProfileKey);
104
}
105
return formData;
106
},
@@ -101,18 +111,13 @@ const model = {
111
return;
112
}
113
104
- if (this.dest === "project" && !this.projectName) {
105
- this.error = "Please select a project";
106
- return;
107
- }
108
-
114
try {
115
this.loading = true;
116
this.loadingMessage = "Previewing skills import...";
117
this.error = "";
118
this.preview = null;
119
115
- const response = await fetchApi("/skills_import_preview", {
120
+ const response = await api.fetchApi("/skills_import_preview", {
121
method: "POST",
122
body: this.buildFormData(),
123
});
@@ -140,18 +145,13 @@ const model = {
145
return;
146
}
147
143
- if (this.dest === "project" && !this.projectName) {
144
- this.error = "Please select a project";
145
- return;
146
- }
147
-
148
try {
149
this.loading = true;
150
this.loadingMessage = "Importing skills...";
151
this.error = "";
152
this.result = null;
153
154
- const response = await fetchApi("/skills_import", {
154
+ const response = await api.fetchApi("/skills_import", {
155
method: "POST",
156
body: this.buildFormData(),
157
});
webui/components/settings/skills/skills-list-store.js
+20
-20
@@ -1,7 +1,5 @@
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";
3
4
const fetchApi = globalThis.fetchApi;
5
@@ -11,12 +9,12 @@ const model = {
9
skills: [],
10
projects: [],
11
projectName: "",
14
- profileName: "",
12
+ agentProfiles: [],
13
+ agentProfileKey: "",
14
15
async init() {
16
this.resetState();
18
- await this.loadProjects();
19
- this.setDefaultProject();
17
+ await Promise.all([this.loadProjects(), this.loadAgentProfiles()]);
18
await this.loadSkills();
19
},
20
@@ -26,31 +24,35 @@ const model = {
24
this.skills = [];
25
this.projects = [];
26
this.projectName = "";
29
- this.profileName = "";
27
+ this.agentProfiles = [];
28
+ this.agentProfileKey = "";
29
},
30
31
onClose() {
32
this.resetState();
33
},
34
36
- setDefaultProject() {
37
- if (this.projectName) return;
38
- const active = chatsStore?.selectedContext?.project?.name;
39
- if (active) {
40
- this.projectName = active;
35
+ async loadAgentProfiles() {
36
+ try {
37
+ const response = await fetchApi("/agents", {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({ action: "list" }),
41
+ });
42
+ const data = await response.json().catch(() => ({}));
43
+ this.agentProfiles = data.ok ? (data.data || []) : [];
44
+ } catch (e) {
45
+ console.error("Failed to load agent profiles:", e);
46
+ this.agentProfiles = [];
47
}
48
},
49
44
- refreshProfileName() {
45
- this.profileName = settingsStore?.settings?.agent_profile || "";
46
- },
47
-
50
async loadProjects() {
51
try {
52
const response = await fetchApi("/projects", {
53
method: "POST",
54
headers: { "Content-Type": "application/json" },
53
- body: JSON.stringify({ action: "list" }),
55
+ body: JSON.stringify({ action: "list_options" }),
56
});
57
const data = await response.json().catch(() => ({}));
58
this.projects = data.ok ? (data.data || []) : [];
@@ -61,7 +63,6 @@ const model = {
63
},
64
65
async loadSkills() {
64
- this.refreshProfileName();
66
try {
67
this.loading = true;
68
this.error = "";
@@ -71,7 +72,7 @@ const model = {
72
body: JSON.stringify({
73
action: "list",
74
project_name: this.projectName || null,
74
- profile_name: this.profileName || null,
75
+ agent_profile: this.agentProfileKey || null,
76
}),
77
});
78
const result = await response.json().catch(() => ({}));
@@ -117,8 +118,7 @@ const model = {
118
},
119
120
async openSkill(skill) {
120
- if (!skill?.location) return;
121
- await fileBrowserStore.open(skill.location);
121
+ await fileBrowserStore.open(skill.path);
122
},
123
};
124
webui/components/settings/skills/skills-settings.html
+1
-1
@@ -17,7 +17,7 @@
17
</li>
18
<li>
19
<a href="#section-skills-import">
20
- <img src="/public/skills.svg" alt="Skills" />
20
+ <img src="/public/skills_add.svg" alt="Skills" />
21
<span>Import Skills</span>
22
</a>
23
</li>
webui/css/modals.css
+2
-2
@@ -200,7 +200,7 @@ some classes like modal-header are shared between the old and the new system */
200
.section {
201
margin-bottom: 2rem;
202
padding: 1rem;
203
- padding-bottom: 0;
203
+ /* padding-bottom: 0; */
204
border: 1px solid var(--color-border);
205
border-radius: 0.5rem;
206
}
@@ -444,7 +444,7 @@ input[type="range"]::-moz-range-thumb {
444
.section {
445
margin-bottom: 1.5rem;
446
padding: 1rem;
447
- padding-bottom: 0;
447
+ /* padding-bottom: 0; */
448
border: 1px solid var(--color-border);
449
border-radius: 0.5rem;
450
}
webui/public/skills.svg
+6
-4
@@ -1,4 +1,6 @@
1
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
2
- <path d="M7 7h10M7 11h10M7 15h6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
3
- <path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="2"/>
4
-</svg>
\ No newline at end of file
1
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3
+<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;">
4
+ <path d="M7,7L17,7M7,11L17,11M7,15L13,15" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:1px;"/>
5
+ <path d="M5,3L19,3C20.097,3 21,3.903 21,5L21,19C21,20.097 20.097,21 19,21L5,21C3.903,21 3,20.097 3,19L3,5C3,3.903 3.903,3 5,3Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:1px;stroke-linecap:butt;"/>
6
+</svg>
webui/public/skills_add.svg
new
+11
@@ -0,0 +1,11 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3
+<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;">
4
+ <g transform="matrix(1,0,0,1,0,5)">
5
+ <path d="M7,7L17,7" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:1px;"/>
6
+ </g>
7
+ <g transform="matrix(6.12323e-17,1,-1,6.12323e-17,19,8.88178e-16)">
8
+ <path d="M7,7L17,7" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:1px;"/>
9
+ </g>
10
+ <path d="M5,3L19,3C20.097,3 21,3.903 21,5L21,19C21,20.097 20.097,21 19,21L5,21C3.903,21 3,20.097 3,19L3,5C3,3.903 3.903,3 5,3Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:1px;stroke-linecap:butt;"/>
11
+</svg>