skills cleanup

frdel committed Feb 5, 2026 at 16:15 UTC c460348549e12e0a694cc27145fe0af0a6bc09e0
6 files changed +96 -206
python/api/skills.py
+24 -20
@@ -1,5 +1,5 @@
1 from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 -from python.helpers import skills
2 +from python.helpers import skills, projects, files
3
4
5 class Skills(ApiHandler):
@@ -25,24 +25,28 @@ class Skills(ApiHandler):
25 }
26
27 def list_skills(self, input: Input):
28 - project_name = (input.get("project_name") or "").strip() or None
29 - profile_name = (input.get("profile_name") or "").strip() or None
30 - return skills.get_skills_list(
31 - project_name=project_name,
32 - profile_name=profile_name
33 - )
28 + skill_list = skills.list_skills()
29 +
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 + skill_list = [
34 + s for s in skill_list if files.is_in_dir(str(s.path), project_folder)
35 + ]
36 +
37 + result = []
38 + for skill in skill_list:
39 + result.append({
40 + "name": skill.name,
41 + "description": skill.description,
42 + "path": str(skill.path),
43 + })
44 + return result
45
46 def delete_skill(self, input: Input):
36 - skill_id = str(input.get("skill_id") or "").strip()
37 - if not skill_id:
38 - raise Exception("skill_id is required")
39 -
40 - project_name = (input.get("project_name") or "").strip() or None
41 - profile_name = (input.get("profile_name") or "").strip() or None
42 -
43 - skills.delete_skill(
44 - skill_id,
45 - project_name=project_name,
46 - profile_name=profile_name,
47 - )
48 - return {"skill_id": skill_id}
47 + skill_path = str(input.get("skill_path") or "").strip()
48 + if not skill_path:
49 + raise Exception("skill_path is required")
50 +
51 + skills.delete_skill(skill_path)
52 + return {"ok": True, "skill_path": skill_path}
python/helpers/history.py
+2 -4
@@ -520,11 +520,9 @@ def output_langchain(messages: list[OutputMessage]):
520 result = []
521 for m in messages:
522 content = _output_content_langchain(content=m["content"])
523 - # Skip AI messages with empty/whitespace-only content
524 - # (API spec requires assistant messages to have content or tool_calls)
523 + if not content or (isinstance(content, str) and not content.strip()):
524 + continue
525 if m["ai"]:
526 - if not content or (isinstance(content, str) and not content.strip()):
527 - continue
526 result.append(AIMessage(content)) # type: ignore
527 else:
528 result.append(HumanMessage(content)) # type: ignore
python/helpers/skills.py
+8 -118
@@ -312,131 +312,21 @@ def list_skills(
312 return list(by_name.values())
313
314
315 -def _get_skill_roots_for_list(
316 - project_name: str | None = None,
317 - profile_name: str | None = None,
318 -) -> List[str]:
319 - """Get skill root directories for the specified scope."""
320 - roots: List[str] = []
321 -
322 - # global roots
323 - roots.append(files.get_abs_path("skills"))
324 - roots.append(files.get_abs_path("usr", "skills"))
325 -
326 - # project roots
327 - if project_name:
328 - if profile_name:
329 - roots.append(projects.get_project_meta_folder(project_name, "agents", profile_name, "skills"))
330 - roots.append(projects.get_project_meta_folder(project_name, "skills"))
331 -
332 - # agent roots
333 - if profile_name:
334 - roots.append(files.get_abs_path(subagents.USER_AGENTS_DIR, profile_name, "skills"))
335 - roots.append(files.get_abs_path(subagents.DEFAULT_AGENTS_DIR, profile_name, "skills"))
336 -
337 - # dedupe and filter existing
338 - seen: set[str] = set()
339 - result: List[str] = []
340 - for root in roots:
341 - if root in seen or not os.path.isdir(root):
342 - continue
343 - seen.add(root)
344 - result.append(root)
345 - return result
346 -
347 -
348 -def _get_scope_info(root: str, project_name: str | None, profile_name: str | None) -> Dict[str, str]:
349 - """Determine scope metadata for a skill root."""
350 - # determine origin
351 - origin = "default"
352 - if files.is_in_base_dir(root):
353 - rel = files.deabsolute_path(root)
354 - if rel.startswith("usr/") or rel.startswith("projects/"):
355 - origin = "user"
356 - if rel.startswith("projects/"):
357 - origin = "project"
358 -
359 - # determine scope
360 - scope = "global"
361 - scope_name = "global"
362 -
363 - if project_name and profile_name and ".a0proj" in root and "agents" in root:
364 - scope = "project_agent"
365 - scope_name = f"{project_name}:{profile_name}"
366 - elif project_name and ".a0proj" in root:
367 - scope = "project"
368 - scope_name = project_name
369 - elif profile_name and f"agents/{profile_name}" in root:
370 - scope = "agent"
371 - scope_name = profile_name
372 -
373 - return {
374 - "scope": scope,
375 - "scope_name": scope_name,
376 - "origin": origin,
377 - }
378 -
379 -
380 -def get_skills_list(
381 - project_name: str | None = None,
382 - profile_name: str | None = None,
383 -) -> List[Dict[str, Any]]:
384 - """Get list of all skills."""
385 - roots = _get_skill_roots_for_list(project_name, profile_name)
386 -
387 - entries: List[Dict[str, Any]] = []
388 - for root in roots:
389 - scope_info = _get_scope_info(root, project_name, profile_name)
390 -
391 - for skill_md in discover_skill_md_files(Path(root)):
392 - skill = skill_from_markdown(skill_md, include_content=False)
393 - if not skill:
394 - continue
395 -
396 - # generate skill_id
397 - rel_path = os.path.relpath(str(skill.path), root).replace("\\", "/")
398 - skill_id = f"{root}:{rel_path}"
399 -
400 - entries.append(
401 - {
402 - "skill_id": skill_id,
403 - "name": skill.name,
404 - "description": skill.description,
405 - "location": files.normalize_a0_path(str(skill.path)),
406 - "root": files.normalize_a0_path(root),
407 - "scope": scope_info["scope"],
408 - "scope_name": scope_info["scope_name"],
409 - "origin": scope_info["origin"],
410 - }
411 - )
412 - return entries
413 -
414 -
315 def delete_skill(
416 - skill_id: str,
417 - project_name: str | None = None,
418 - profile_name: str | None = None,
316 + skill_path: str,
317 ) -> None:
318 """Delete a skill directory."""
421 - if not skill_id or ":" not in skill_id:
422 - raise ValueError("Invalid skill_id")
319
424 - root, rel_path = skill_id.split(":", 1)
425 - if not rel_path or rel_path in ("", "."):
426 - raise ValueError("Cannot delete root directory")
320 + skill_path = files.get_abs_path(skill_path)
321
428 - allowed_roots = _get_skill_roots_for_list(project_name, profile_name)
429 - if root not in allowed_roots:
322 + allowed_roots = get_skill_roots()
323 + for root in allowed_roots:
324 + if files.is_in_dir(skill_path, root):
325 + break
326 + else:
327 raise ValueError("Skill root not in current scope")
328
432 - # construct and validate path (prevent directory traversal)
433 - root_abs = os.path.abspath(root)
434 - skill_path = os.path.abspath(os.path.join(root, rel_path))
435 -
436 - # security check: ensure skill_path is within root
437 - if not skill_path.startswith(root_abs + os.sep) and skill_path != root_abs:
438 - raise ValueError("Invalid path: directory traversal detected")
439 -
329 +
330 if not os.path.isdir(skill_path):
331 raise FileNotFoundError("Skill directory not found")
332
python/tools/skills_tool.py
+59 -59
@@ -37,12 +37,12 @@ class SkillsTool(Tool):
37 if method == "load":
38 skill_name = str(kwargs.get("skill_name") or "").strip()
39 return Response(message=self._load(skill_name), break_loop=False)
40 - if method == "read_file":
41 - skill_name = str(kwargs.get("skill_name") or "").strip()
42 - file_path = str(kwargs.get("file_path") or "").strip()
43 - return Response(
44 - message=self._read_file(skill_name, file_path), break_loop=False
45 - )
40 + # if method == "read_file":
41 + # skill_name = str(kwargs.get("skill_name") or "").strip()
42 + # file_path = str(kwargs.get("file_path") or "").strip()
43 + # return Response(
44 + # message=self._read_file(skill_name, file_path), break_loop=False
45 + # )
46
47 return Response(
48 message=(
@@ -80,30 +80,30 @@ class SkillsTool(Tool):
80 lines.append("Tip: use skills_tool method=search or method=load for details.")
81 return "\n".join(lines)
82
83 - def _search(self, query: str) -> str:
84 - if not query:
85 - return "Error: 'query' is required for method=search."
86 -
87 - results = skills_helper.search_skills(
88 - query,
89 - limit=25,
90 - agent=self.agent,
91 - )
92 - if not results:
93 - return f"No skills matched query: {query!r}"
94 -
95 - lines: List[str] = []
96 - lines.append(f"Skills matching {query!r} ({len(results)}):")
97 - for s in results:
98 - desc = (s.description or "").strip()
99 - if len(desc) > 200:
100 - desc = desc[:200].rstrip() + "…"
101 - lines.append(f"- {s.name}: {desc}")
102 - lines.append("")
103 - lines.append(
104 - "Tip: use skills_tool method=load skill_name=<name> to load full instructions."
105 - )
106 - return "\n".join(lines)
83 + # def _search(self, query: str) -> str:
84 + # if not query:
85 + # return "Error: 'query' is required for method=search."
86 +
87 + # results = skills_helper.search_skills(
88 + # query,
89 + # limit=25,
90 + # agent=self.agent,
91 + # )
92 + # if not results:
93 + # return f"No skills matched query: {query!r}"
94 +
95 + # lines: List[str] = []
96 + # lines.append(f"Skills matching {query!r} ({len(results)}):")
97 + # for s in results:
98 + # desc = (s.description or "").strip()
99 + # if len(desc) > 200:
100 + # desc = desc[:200].rstrip() + "…"
101 + # lines.append(f"- {s.name}: {desc}")
102 + # lines.append("")
103 + # lines.append(
104 + # "Tip: use skills_tool method=load skill_name=<name> to load full instructions."
105 + # )
106 + # return "\n".join(lines)
107
108 def _load(self, skill_name: str) -> str:
109
@@ -171,35 +171,35 @@ class SkillsTool(Tool):
171
172 return "\n".join(lines)
173
174 - def _read_file(self, skill_name: str, file_path: str) -> str:
175 - if not skill_name:
176 - return "Error: 'skill_name' is required for method=read_file."
177 - if not file_path:
178 - return "Error: 'file_path' is required for method=read_file."
179 -
180 - skill = skills_helper.find_skill(
181 - skill_name,
182 - include_content=False,
183 - agent=self.agent,
184 - )
185 - if not skill:
186 - return f"Error: skill not found: {skill_name!r}."
187 -
188 - try:
189 - target = skills_helper.safe_path_within_dir(skill.path, file_path)
190 - except Exception as e:
191 - return f"Error: invalid file_path: {e}"
192 -
193 - if not target.exists() or not target.is_file():
194 - return f"Error: file not found: {file_path!r} (within skill {skill.name})"
195 -
196 - # Basic binary guard: if null byte present, do not dump
197 - content = target.read_bytes()
198 - if b"\x00" in content[:4096]:
199 - return f"Error: file appears to be binary; refusing to print raw bytes ({file_path})."
200 -
201 - text = content.decode("utf-8", errors="replace")
202 - return f"File: {file_path}\n\n{text}"
174 + # def _read_file(self, skill_name: str, file_path: str) -> str:
175 + # if not skill_name:
176 + # return "Error: 'skill_name' is required for method=read_file."
177 + # if not file_path:
178 + # return "Error: 'file_path' is required for method=read_file."
179 +
180 + # skill = skills_helper.find_skill(
181 + # skill_name,
182 + # include_content=False,
183 + # agent=self.agent,
184 + # )
185 + # if not skill:
186 + # return f"Error: skill not found: {skill_name!r}."
187 +
188 + # try:
189 + # target = skills_helper.safe_path_within_dir(skill.path, file_path)
190 + # except Exception as e:
191 + # return f"Error: invalid file_path: {e}"
192 +
193 + # if not target.exists() or not target.is_file():
194 + # return f"Error: file not found: {file_path!r} (within skill {skill.name})"
195 +
196 + # # Basic binary guard: if null byte present, do not dump
197 + # content = target.read_bytes()
198 + # if b"\x00" in content[:4096]:
199 + # return f"Error: file appears to be binary; refusing to print raw bytes ({file_path})."
200 +
201 + # text = content.decode("utf-8", errors="replace")
202 + # return f"File: {file_path}\n\n{text}"
203
204 def _list_skill_files(self, skill_dir: Path, *, max_files: int = 80) -> str:
205 if not skill_dir.exists():
webui/components/settings/skills/list.html
+2 -2
@@ -56,7 +56,7 @@
56 </template>
57
58 <div class="skills-list">
59 - <template x-for="skill in $store.skillsListStore.skills" :key="skill.skill_id">
59 + <template x-for="skill in $store.skillsListStore.skills" :key="skill.path">
60 <div class="skill-card">
61 <div class="skill-header">
62 <div class="skill-title" x-text="skill.name || '(unnamed skill)'"></div>
@@ -74,7 +74,7 @@
74 <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.location"></code>
77 + <code x-text="skill.path"></code>
78 </div>
79 </div>
80 </template>
webui/components/settings/skills/skills-list-store.js
+1 -3
@@ -97,9 +97,7 @@ const model = {
97 headers: { "Content-Type": "application/json" },
98 body: JSON.stringify({
99 action: "delete",
100 - skill_id: skill.skill_id,
101 - project_name: this.projectName || null,
102 - profile_name: this.profileName || null,
100 + skill_path: skill.path,
101 }),
102 });
103 const result = await response.json().catch(() => ({}));