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 &gt; 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>