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(() => ({}));