- Added a new Skills section in settings with import functionality - Introduced Skills tab in the web UI for better user interaction

- Added a new Skills section in settings with import functionality - Introduced Skills tab in the web UI for better user interaction

TerminallyLazy committed Dec 30, 2025 at 01:48 UTC 3d348c0ed55223c17615ee9a41d707c5d73f3b00
19 files changed +1825 -20
prompts/agent.system.skills.md new
+5
@@ -0,0 +1,5 @@
1 +# Relevant Skills (SKILL.md)
2 +- The following Skills may be useful for the current task.
3 +- Use `skills_tool` to list/search/load skills and progressively read supporting files/scripts.
4 +
5 +{{skills}}
python/api/skills_import.py new
+88
@@ -0,0 +1,88 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import time
5 +import uuid
6 +from pathlib import Path
7 +
8 +from python.helpers.api import ApiHandler, Request, Response
9 +from python.helpers import files
10 +from python.helpers.skills_import import import_skills
11 +from werkzeug.datastructures import FileStorage
12 +from werkzeug.utils import secure_filename
13 +
14 +
15 +class SkillsImport(ApiHandler):
16 + """
17 + Import an external skills pack (.zip) into skills/shared/<namespace>/...
18 + Performs the actual import (not dry-run).
19 + """
20 +
21 + async def process(self, input: dict, request: Request) -> dict | Response:
22 + if "skills_file" not in request.files:
23 + return {"success": False, "error": "No skills file provided"}
24 +
25 + skills_file: FileStorage = request.files["skills_file"]
26 + if not skills_file.filename:
27 + return {"success": False, "error": "No file selected"}
28 +
29 + ctxid = request.form.get("ctxid", "")
30 + if not ctxid:
31 + return {"success": False, "error": "No context id provided"}
32 + _context = self.use_context(ctxid)
33 +
34 + dest = (request.form.get("dest", "shared") or "shared").strip().lower()
35 + if dest not in ("shared", "custom", "project"):
36 + dest = "shared"
37 +
38 + conflict = (request.form.get("conflict", "skip") or "skip").strip().lower()
39 + if conflict not in ("skip", "overwrite", "rename"):
40 + conflict = "skip"
41 +
42 + namespace = (request.form.get("namespace", "") or "").strip() or None
43 + 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'"}
48 +
49 + # Save upload to a temp file so we can pass a filesystem path to the importer
50 + tmp_dir = Path(files.get_abs_path("tmp", "uploads"))
51 + tmp_dir.mkdir(parents=True, exist_ok=True)
52 + base = secure_filename(skills_file.filename) # type: ignore[arg-type]
53 + if not base.lower().endswith(".zip"):
54 + base = f"{base}.zip"
55 + unique = uuid.uuid4().hex[:8]
56 + stamp = time.strftime("%Y%m%d_%H%M%S")
57 + tmp_path = tmp_dir / f"skills_import_{stamp}_{unique}_{base}"
58 + skills_file.save(str(tmp_path))
59 +
60 + try:
61 + result = import_skills(
62 + str(tmp_path),
63 + dest_subdir=dest, # type: ignore[arg-type]
64 + namespace=namespace,
65 + conflict=conflict, # type: ignore[arg-type]
66 + dry_run=False, # Actual import, not preview
67 + project_name=project_name,
68 + )
69 +
70 + imported = [files.deabsolute_path(str(p)) for p in result.imported]
71 + skipped = [files.deabsolute_path(str(p)) for p in result.skipped]
72 + dest_root = files.deabsolute_path(str(result.destination_root / result.namespace))
73 +
74 + return {
75 + "success": True,
76 + "namespace": result.namespace,
77 + "destination": dest_root,
78 + "imported": imported,
79 + "skipped": skipped,
80 + "imported_count": len(imported),
81 + "skipped_count": len(skipped),
82 + "conflict_policy": conflict,
83 + }
84 + finally:
85 + try:
86 + tmp_path.unlink(missing_ok=True) # type: ignore[arg-type]
87 + except Exception:
88 + pass
python/api/skills_import_preview.py new
+89
@@ -0,0 +1,89 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import time
5 +import uuid
6 +from pathlib import Path
7 +
8 +from python.helpers.api import ApiHandler, Request, Response
9 +from python.helpers import files
10 +from python.helpers.skills_import import import_skills
11 +from werkzeug.datastructures import FileStorage
12 +from werkzeug.utils import secure_filename
13 +
14 +
15 +class SkillsImportPreview(ApiHandler):
16 + """
17 + Preview importing an external skills pack (.zip) into skills/shared/<namespace>/...
18 + Uses dry-run (no copying).
19 + """
20 +
21 + async def process(self, input: dict, request: Request) -> dict | Response:
22 + if "skills_file" not in request.files:
23 + return {"success": False, "error": "No skills file provided"}
24 +
25 + skills_file: FileStorage = request.files["skills_file"]
26 + if not skills_file.filename:
27 + return {"success": False, "error": "No file selected"}
28 +
29 + ctxid = request.form.get("ctxid", "")
30 + if not ctxid:
31 + return {"success": False, "error": "No context id provided"}
32 + _context = self.use_context(ctxid)
33 +
34 + dest = (request.form.get("dest", "shared") or "shared").strip().lower()
35 + if dest not in ("shared", "custom", "project"):
36 + dest = "shared"
37 +
38 + conflict = (request.form.get("conflict", "skip") or "skip").strip().lower()
39 + if conflict not in ("skip", "overwrite", "rename"):
40 + conflict = "skip"
41 +
42 + namespace = (request.form.get("namespace", "") or "").strip() or None
43 + 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'"}
48 +
49 + # Save upload to a temp file so we can pass a filesystem path to the importer
50 + tmp_dir = Path(files.get_abs_path("tmp", "uploads"))
51 + tmp_dir.mkdir(parents=True, exist_ok=True)
52 + base = secure_filename(skills_file.filename) # type: ignore[arg-type]
53 + if not base.lower().endswith(".zip"):
54 + base = f"{base}.zip"
55 + unique = uuid.uuid4().hex[:8]
56 + stamp = time.strftime("%Y%m%d_%H%M%S")
57 + tmp_path = tmp_dir / f"skills_import_preview_{stamp}_{unique}_{base}"
58 + skills_file.save(str(tmp_path))
59 +
60 + try:
61 + result = import_skills(
62 + str(tmp_path),
63 + dest_subdir=dest, # type: ignore[arg-type]
64 + namespace=namespace,
65 + conflict=conflict, # type: ignore[arg-type]
66 + dry_run=True,
67 + project_name=project_name,
68 + )
69 +
70 + imported = [files.deabsolute_path(str(p)) for p in result.imported]
71 + skipped = [files.deabsolute_path(str(p)) for p in result.skipped]
72 + dest_root = files.deabsolute_path(str(result.destination_root / result.namespace))
73 +
74 + return {
75 + "success": True,
76 + "namespace": result.namespace,
77 + "destination": dest_root,
78 + "imported": imported,
79 + "skipped": skipped,
80 + "imported_count": len(imported),
81 + "skipped_count": len(skipped),
82 + "conflict_policy": conflict,
83 + }
84 + finally:
85 + try:
86 + tmp_path.unlink(missing_ok=True) # type: ignore[arg-type]
87 + except Exception:
88 + pass
89 +
python/extensions/message_loop_prompts_after/_55_recall_skills.py new
+109
@@ -0,0 +1,109 @@
1 +import os
2 +from pathlib import Path
3 +
4 +from python.helpers.extension import Extension
5 +from agent import LoopData
6 +from python.helpers.memory import Memory
7 +from python.helpers import files
8 +from python.helpers import skills as skills_helper
9 +
10 +
11 +class RecallSkills(Extension):
12 + """
13 + Surface relevant SKILL.md-based Skills into the prompt (token-efficient).
14 +
15 + The Memory subsystem already indexes `skills/**/SKILL.md` into area "skills".
16 + This extension does a lightweight similarity lookup and injects a small
17 + "relevant skills" list into extras for the current user message.
18 + """
19 +
20 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
21 + # Only on the first iteration of the message loop (new user instruction)
22 + if loop_data.iteration != 0:
23 + return
24 +
25 + # Determine query from current user message
26 + user_instruction = (
27 + loop_data.user_message.output_text() if loop_data.user_message else ""
28 + ).strip()
29 + if not user_instruction or len(user_instruction) < 8:
30 + return
31 +
32 + try:
33 + db = await Memory.get(self.agent)
34 + docs = await db.search_similarity_threshold(
35 + query=user_instruction,
36 + limit=12,
37 + threshold=0.55,
38 + filter=f"area == '{Memory.Area.SKILLS.value}'",
39 + )
40 + except Exception:
41 + docs = []
42 +
43 + # Fallback: simple keyword search over discovered skills if vector recall yields nothing
44 + recalled = []
45 + if docs:
46 + seen = set()
47 + for doc in docs:
48 + src = (doc.metadata or {}).get("source_path") or ""
49 + if not src:
50 + continue
51 + if src in seen:
52 + continue
53 + seen.add(src)
54 + recalled.append(src)
55 + if len(recalled) >= 6:
56 + break
57 +
58 + if not recalled:
59 + # cheap lexical fallback
60 + matches = skills_helper.search_skills(user_instruction, limit=6)
61 + for s in matches:
62 + recalled.append(str(s.skill_md_path))
63 +
64 + if not recalled:
65 + return
66 +
67 + # Build compact metadata list
68 + base_skills_dir = Path(files.get_abs_path("skills")).resolve()
69 + lines = []
70 + for src_path in recalled[:6]:
71 + try:
72 + p = Path(src_path)
73 + # Some docs may store /a0/... paths; map to dev path when needed
74 + abs_path = Path(files.fix_dev_path(str(p)))
75 + text = abs_path.read_text(encoding="utf-8", errors="replace")
76 + fm, body = skills_helper.split_frontmatter(text)
77 +
78 + # Infer source if possible (custom/builtin/shared), else "unknown"
79 + source = "unknown"
80 + try:
81 + rel = abs_path.resolve().relative_to(base_skills_dir)
82 + if rel.parts and rel.parts[0] in ("custom", "builtin", "shared"):
83 + source = rel.parts[0]
84 + except Exception:
85 + pass
86 +
87 + name = str(fm.get("name") or abs_path.parent.name).strip()
88 + desc = str(fm.get("description") or "").strip()
89 + if not desc:
90 + # fallback to first non-empty line of body
91 + for line in (body or "").splitlines():
92 + if line.strip():
93 + desc = line.strip()
94 + break
95 + if len(desc) > 220:
96 + desc = desc[:220].rstrip() + "…"
97 +
98 + lines.append(f"- {name} [{source}]: {desc}")
99 + except Exception:
100 + continue
101 +
102 + if not lines:
103 + return
104 +
105 + skills_block = "\n".join(lines)
106 + loop_data.extras_temporary["skills"] = self.agent.parse_prompt(
107 + "agent.system.skills.md", skills=skills_block
108 + )
109 +
python/helpers/mcp_server.py
+40 -16
@@ -318,15 +318,20 @@ class DynamicMcpProxy:
318 http_path = f"/t-{self.token}/http"
319 message_path = f"/t-{self.token}/messages/"
320
321 + # Update settings in the MCP server instance if provided
322 + mcp_server.settings.message_path = message_path
323 + mcp_server.settings.sse_path = sse_path
324 +
325 # Create new MCP apps with updated settings
326 with self._lock:
327 self.sse_app = create_sse_app(
328 server=mcp_server,
325 - message_path=message_path,
326 - sse_path=sse_path,
327 - auth=None, # No auth configured
328 - debug=False,
329 - routes=[],
329 + message_path=mcp_server.settings.message_path,
330 + sse_path=mcp_server.settings.sse_path,
331 + auth_server_provider=mcp_server._auth_server_provider,
332 + auth_settings=mcp_server.settings.auth,
333 + debug=mcp_server.settings.debug,
334 + routes=mcp_server._additional_http_routes,
335 middleware=[Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)],
336 )
337
@@ -334,12 +339,13 @@ class DynamicMcpProxy:
339 # doesn't work properly in our Flask/Werkzeug environment
340 self.http_app = self._create_custom_http_app(
341 http_path,
337 - None, # No auth configured
338 - False,
339 - [],
342 + mcp_server._auth_server_provider,
343 + mcp_server.settings.auth,
344 + mcp_server.settings.debug,
345 + mcp_server._additional_http_routes,
346 )
347
342 - def _create_custom_http_app(self, streamable_http_path, auth, debug, routes):
348 + def _create_custom_http_app(self, streamable_http_path, auth_server_provider, auth_settings, debug, routes):
349 """Create a custom HTTP app that manages the session manager manually."""
350 from fastmcp.server.http import setup_auth_middleware_and_routes, create_base_app # type: ignore
351 from mcp.server.streamable_http_manager import StreamableHTTPSessionManager # type: ignore
@@ -352,6 +358,7 @@ class DynamicMcpProxy:
358
359 self.http_session_task_group = None
360
361 +
362 # Create session manager
363 self.http_session_manager = StreamableHTTPSessionManager(
364 app=mcp_server._mcp_server,
@@ -360,6 +367,7 @@ class DynamicMcpProxy:
367 stateless=False,
368 )
369
370 +
371 # Custom ASGI handler that ensures task group is initialized
372 async def handle_streamable_http(scope, receive, send):
373 # Lazy initialization of task group
@@ -372,14 +380,30 @@ class DynamicMcpProxy:
380 if self.http_session_manager:
381 await self.http_session_manager.handle_request(scope, receive, send)
382
375 - # Add StreamableHTTP route (no auth)
376 - server_routes.append(
377 - Mount(
378 - streamable_http_path,
379 - app=handle_streamable_http,
380 - )
383 + # Get auth middleware and routes
384 + auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
385 + auth_server_provider, auth_settings
386 )
387
388 + server_routes.extend(auth_routes)
389 + server_middleware.extend(auth_middleware)
390 +
391 + # Add StreamableHTTP routes with or without auth
392 + if auth_server_provider:
393 + server_routes.append(
394 + Mount(
395 + streamable_http_path,
396 + app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
397 + )
398 + )
399 + else:
400 + server_routes.append(
401 + Mount(
402 + streamable_http_path,
403 + app=handle_streamable_http,
404 + )
405 + )
406 +
407 # Add custom routes with lowest precedence
408 if routes:
409 server_routes.extend(routes)
@@ -460,4 +484,4 @@ async def mcp_middleware(request: Request, call_next):
484 status_code=403, detail="MCP server is disabled in settings."
485 )
486
463 - return await call_next(request)
487 + return await call_next(request)
\ No newline at end of file
python/helpers/settings.py
+22
@@ -1275,6 +1275,28 @@ def convert_out(settings: Settings) -> SettingsOutput:
1275 "tab": "external",
1276 }
1277
1278 + # Skills section (UI-friendly import)
1279 + skills_fields: list[SettingsField] = []
1280 + skills_fields.append(
1281 + {
1282 + "id": "skills_import",
1283 + "title": "Import Skills",
1284 + "description": "Import a Skills pack (.zip) into <code>skills/shared/&lt;namespace&gt;/...</code> "
1285 + "using the open Agent Skills (SKILL.md) standard.",
1286 + "type": "button",
1287 + "value": "Import Skills",
1288 + }
1289 + )
1290 +
1291 + skills_section: SettingsSection = {
1292 + "id": "skills",
1293 + "title": "Skills",
1294 + "description": "Skills are portable bundles of instructions, scripts, and resources that agents can discover and load on demand. "
1295 + "See the spec at <code>agentskills.io</code>.",
1296 + "fields": skills_fields,
1297 + "tab": "agent",
1298 + }
1299 +
1300 # Backup & Restore section
1301 backup_fields: list[SettingsField] = []
1302
python/helpers/skills.py new
+340
@@ -0,0 +1,340 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import re
5 +from dataclasses import dataclass, field
6 +from pathlib import Path
7 +from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
8 +
9 +from python.helpers import files
10 +
11 +try:
12 + import yaml # type: ignore
13 +except Exception: # pragma: no cover
14 + yaml = None # type: ignore
15 +
16 +
17 +SkillSource = Literal["custom", "builtin", "shared"]
18 +
19 +
20 +@dataclass(slots=True)
21 +class Skill:
22 + name: str
23 + description: str
24 + path: Path
25 + skill_md_path: Path
26 + source: SkillSource
27 +
28 + version: str = ""
29 + author: str = ""
30 + tags: List[str] = field(default_factory=list)
31 + triggers: List[str] = field(default_factory=list)
32 + allowed_tools: List[str] = field(default_factory=list)
33 + license: str = ""
34 + metadata: Dict[str, Any] = field(default_factory=dict)
35 +
36 + # Optional heavy fields (only set when requested)
37 + content: str = "" # body content (markdown without frontmatter)
38 + raw_frontmatter: Dict[str, Any] = field(default_factory=dict)
39 +
40 +
41 +def get_skills_base_dir() -> Path:
42 + return Path(files.get_abs_path("skills"))
43 +
44 +
45 +def get_skill_roots(order: Optional[List[SkillSource]] = None) -> List[Tuple[SkillSource, Path]]:
46 + base = get_skills_base_dir()
47 + order = order or ["custom", "builtin", "shared"]
48 + return [(src, base / src) for src in order]
49 +
50 +
51 +def _is_hidden_path(path: Path) -> bool:
52 + return any(part.startswith(".") for part in path.parts)
53 +
54 +
55 +def discover_skill_md_files(root: Path) -> List[Path]:
56 + """
57 + Recursively discover SKILL.md files under a root directory.
58 + Hidden folders/files are ignored.
59 + """
60 + if not root.exists():
61 + return []
62 +
63 + results: List[Path] = []
64 + for p in root.rglob("SKILL.md"):
65 + try:
66 + if not p.is_file():
67 + continue
68 + if _is_hidden_path(p.relative_to(root)):
69 + continue
70 + results.append(p)
71 + except Exception:
72 + # If relative_to fails (weird symlink), fall back to conservative checks
73 + if p.is_file() and ".git" not in str(p):
74 + results.append(p)
75 + results.sort(key=lambda x: str(x))
76 + return results
77 +
78 +
79 +def _coerce_list(value: Any) -> List[str]:
80 + if value is None:
81 + return []
82 + if isinstance(value, list):
83 + return [str(v).strip() for v in value if str(v).strip()]
84 + if isinstance(value, tuple):
85 + return [str(v).strip() for v in list(value) if str(v).strip()]
86 + if isinstance(value, str):
87 + # Support comma-separated strings
88 + parts = [p.strip() for p in value.split(",")]
89 + return [p for p in parts if p]
90 + return [str(value).strip()] if str(value).strip() else []
91 +
92 +
93 +def _normalize_name(name: str) -> str:
94 + return re.sub(r"\s+", "-", (name or "").strip().lower())
95 +
96 +
97 +def _read_text(path: Path) -> str:
98 + return path.read_text(encoding="utf-8", errors="replace")
99 +
100 +
101 +def split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str]:
102 + """
103 + Splits a SKILL.md into (frontmatter_dict, body_text).
104 +
105 + If no YAML frontmatter is present, returns ({}, full_text).
106 + """
107 + text = markdown or ""
108 + if not text.lstrip().startswith("---"):
109 + return {}, text.strip()
110 +
111 + # We require frontmatter fence at the start (allow leading whitespace/newlines).
112 + lines = text.splitlines()
113 + # find first '---' line
114 + start_idx = None
115 + for i, line in enumerate(lines):
116 + if line.strip() == "---":
117 + start_idx = i
118 + break
119 + if line.strip(): # non-empty before fence => not frontmatter
120 + return {}, text.strip()
121 +
122 + if start_idx is None:
123 + return {}, text.strip()
124 +
125 + end_idx = None
126 + for j in range(start_idx + 1, len(lines)):
127 + if lines[j].strip() == "---":
128 + end_idx = j
129 + break
130 +
131 + if end_idx is None:
132 + return {}, text.strip()
133 +
134 + fm_text = "\n".join(lines[start_idx + 1 : end_idx]).strip()
135 + body = "\n".join(lines[end_idx + 1 :]).strip()
136 + fm = parse_frontmatter(fm_text)
137 + return fm, body
138 +
139 +
140 +def parse_frontmatter(frontmatter_text: str) -> Dict[str, Any]:
141 + """
142 + Parse YAML frontmatter. Uses PyYAML if available, otherwise a minimal fallback parser.
143 + """
144 + if not frontmatter_text.strip():
145 + return {}
146 +
147 + if yaml is not None:
148 + try:
149 + parsed = yaml.safe_load(frontmatter_text) # type: ignore[attr-defined]
150 + return parsed if isinstance(parsed, dict) else {}
151 + except Exception:
152 + return {}
153 +
154 + # Fallback: very small YAML subset (key: value, lists with '- item')
155 + data: Dict[str, Any] = {}
156 + current_key: Optional[str] = None
157 + for raw in frontmatter_text.splitlines():
158 + line = raw.rstrip()
159 + if not line.strip() or line.strip().startswith("#"):
160 + continue
161 +
162 + m = re.match(r"^([A-Za-z0-9_.-]+)\s*:\s*(.*)$", line)
163 + if m:
164 + key = m.group(1)
165 + val = m.group(2).strip()
166 + current_key = key
167 + if val == "":
168 + data[key] = []
169 + else:
170 + # strip surrounding quotes
171 + if (val.startswith('"') and val.endswith('"')) or (
172 + val.startswith("'") and val.endswith("'")
173 + ):
174 + val = val[1:-1]
175 + data[key] = val
176 + continue
177 +
178 + m_list = re.match(r"^\s*-\s*(.*)$", line)
179 + if m_list and current_key:
180 + item = m_list.group(1).strip()
181 + if (item.startswith('"') and item.endswith('"')) or (
182 + item.startswith("'") and item.endswith("'")
183 + ):
184 + item = item[1:-1]
185 + if not isinstance(data.get(current_key), list):
186 + data[current_key] = []
187 + data[current_key].append(item)
188 + continue
189 +
190 + return data
191 +
192 +
193 +def skill_from_markdown(
194 + skill_md_path: Path,
195 + source: SkillSource,
196 + *,
197 + include_content: bool = False,
198 +) -> Optional[Skill]:
199 + try:
200 + text = _read_text(skill_md_path)
201 + except Exception:
202 + return None
203 +
204 + fm, body = split_frontmatter(text)
205 + skill_dir = skill_md_path.parent
206 +
207 + name = str(fm.get("name") or fm.get("skill") or skill_dir.name).strip()
208 + description = str(
209 + fm.get("description") or fm.get("when_to_use") or fm.get("summary") or ""
210 + ).strip()
211 +
212 + # Cross-platform aliases:
213 + # - Claude Code leans on description (triggers may be embedded there)
214 + # - Some repos use triggers/trigger_patterns
215 + triggers = _coerce_list(
216 + fm.get("triggers")
217 + or fm.get("trigger_patterns")
218 + or fm.get("trigger")
219 + or fm.get("activation")
220 + )
221 +
222 + tags = _coerce_list(fm.get("tags") or fm.get("tag"))
223 + allowed_tools = _coerce_list(fm.get("allowed_tools") or fm.get("tools"))
224 +
225 + version = str(fm.get("version") or "").strip()
226 + author = str(fm.get("author") or "").strip()
227 + license_ = str(fm.get("license") or "").strip()
228 +
229 + meta = fm.get("metadata")
230 + if not isinstance(meta, dict):
231 + meta = {}
232 +
233 + skill = Skill(
234 + name=name,
235 + description=description,
236 + path=skill_dir,
237 + skill_md_path=skill_md_path,
238 + source=source,
239 + version=version,
240 + author=author,
241 + tags=tags,
242 + triggers=triggers,
243 + allowed_tools=allowed_tools,
244 + license=license_,
245 + metadata=dict(meta),
246 + raw_frontmatter=fm if include_content else {},
247 + content=body if include_content else "",
248 + )
249 + return skill
250 +
251 +
252 +def list_skills(
253 + *,
254 + include_content: bool = False,
255 + dedupe: bool = True,
256 + root_order: Optional[List[SkillSource]] = None,
257 +) -> List[Skill]:
258 + skills: List[Skill] = []
259 +
260 + roots = get_skill_roots(order=root_order)
261 + for source, root in roots:
262 + for skill_md in discover_skill_md_files(root):
263 + s = skill_from_markdown(skill_md, source, include_content=include_content)
264 + if s:
265 + skills.append(s)
266 +
267 + if not dedupe:
268 + return skills
269 +
270 + # Dedupe by normalized name, preserving root_order priority (earlier wins)
271 + by_name: Dict[str, Skill] = {}
272 + for s in skills:
273 + key = _normalize_name(s.name) or _normalize_name(s.path.name)
274 + if key and key not in by_name:
275 + by_name[key] = s
276 + return list(by_name.values())
277 +
278 +
279 +def find_skill(
280 + skill_name: str,
281 + *,
282 + include_content: bool = False,
283 + root_order: Optional[List[SkillSource]] = None,
284 +) -> Optional[Skill]:
285 + target = _normalize_name(skill_name)
286 + if not target:
287 + return None
288 +
289 + roots = get_skill_roots(order=root_order)
290 + for source, root in roots:
291 + for skill_md in discover_skill_md_files(root):
292 + s = skill_from_markdown(skill_md, source, include_content=include_content)
293 + if not s:
294 + continue
295 + if _normalize_name(s.name) == target or _normalize_name(s.path.name) == target:
296 + return s
297 + return None
298 +
299 +
300 +def search_skills(query: str, *, limit: int = 25) -> List[Skill]:
301 + q = (query or "").strip().lower()
302 + if not q:
303 + return []
304 +
305 + terms = [t for t in re.split(r"\s+", q) if t]
306 + candidates = list_skills(include_content=False, dedupe=True)
307 +
308 + scored: List[Tuple[int, Skill]] = []
309 + for s in candidates:
310 + name = s.name.lower()
311 + desc = (s.description or "").lower()
312 + tags = [t.lower() for t in s.tags]
313 +
314 + score = 0
315 + for term in terms:
316 + if term in name:
317 + score += 3
318 + if term in desc:
319 + score += 2
320 + if any(term in tag for tag in tags):
321 + score += 1
322 +
323 + if score > 0:
324 + scored.append((score, s))
325 +
326 + scored.sort(key=lambda pair: (-pair[0], pair[1].name))
327 + return [s for _score, s in scored[:limit]]
328 +
329 +
330 +def safe_path_within_dir(base_dir: Path, rel_path: str) -> Path:
331 + """
332 + Resolve rel_path inside base_dir, preventing directory traversal.
333 + """
334 + base = base_dir.resolve()
335 + candidate = (base / rel_path).resolve()
336 + if os.path.commonpath([str(candidate), str(base)]) != str(base):
337 + raise ValueError("Path escapes skill directory")
338 + return candidate
339 +
340 +
python/helpers/skills_import.py new
+251
@@ -0,0 +1,251 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import tempfile
6 +import time
7 +import zipfile
8 +from dataclasses import dataclass
9 +from pathlib import Path
10 +from typing import Iterable, List, Literal, Optional, Tuple
11 +
12 +from python.helpers import files
13 +from python.helpers.skills import discover_skill_md_files
14 +
15 +
16 +ConflictPolicy = Literal["skip", "overwrite", "rename"]
17 +DestSubdir = Literal["shared", "custom", "project"]
18 +
19 +# Project skills folder name (inside .a0proj)
20 +PROJECT_SKILLS_DIR = "skills"
21 +
22 +
23 +@dataclass(slots=True)
24 +class ImportPlanItem:
25 + src_root: Path
26 + src_skill_dir: Path
27 + dest_skill_dir: Path
28 +
29 +
30 +@dataclass(slots=True)
31 +class ImportResult:
32 + imported: List[Path]
33 + skipped: List[Path]
34 + source_root: Path
35 + destination_root: Path
36 + namespace: str
37 +
38 +
39 +def _is_within(child: Path, parent: Path) -> bool:
40 + try:
41 + child.resolve().relative_to(parent.resolve())
42 + return True
43 + except Exception:
44 + return False
45 +
46 +
47 +def _derive_namespace(source: Path) -> str:
48 + # Use stem for zip, name for directory
49 + return (source.stem or source.name or "import").strip()
50 +
51 +
52 +def _candidate_skill_roots(source_dir: Path) -> List[Path]:
53 + """
54 + Heuristics to find likely skill roots inside a repo/pack:
55 + - <source>/skills
56 + - <source>/plugins/*/skills (Claude Code style)
57 + - fallback: <source>
58 + """
59 + candidates: List[Path] = []
60 +
61 + direct = source_dir / "skills"
62 + if direct.is_dir() and discover_skill_md_files(direct):
63 + candidates.append(direct)
64 +
65 + plugins = source_dir / "plugins"
66 + if plugins.is_dir():
67 + for child in plugins.iterdir():
68 + if not child.is_dir():
69 + continue
70 + skills_dir = child / "skills"
71 + if skills_dir.is_dir() and discover_skill_md_files(skills_dir):
72 + candidates.append(skills_dir)
73 +
74 + # Deduplicate while preserving order
75 + unique: List[Path] = []
76 + seen = set()
77 + for c in candidates:
78 + key = str(c.resolve())
79 + if key not in seen:
80 + seen.add(key)
81 + unique.append(c)
82 +
83 + return unique or [source_dir]
84 +
85 +
86 +def _unzip_to_temp_dir(zip_path: Path) -> Path:
87 + """
88 + Extract a zip into a temp folder under tmp/skill_imports (inside Agent Zero base dir).
89 + Returns the extraction root folder.
90 + """
91 + base_tmp = Path(files.get_abs_path("tmp", "skill_imports"))
92 + base_tmp.mkdir(parents=True, exist_ok=True)
93 + stamp = time.strftime("%Y%m%d_%H%M%S")
94 + target = base_tmp / f"import_{zip_path.stem}_{stamp}"
95 + target.mkdir(parents=True, exist_ok=True)
96 +
97 + with zipfile.ZipFile(zip_path, "r") as z:
98 + z.extractall(target)
99 +
100 + # If zip contains a single top-level folder, treat that as the root
101 + children = [p for p in target.iterdir()]
102 + if len(children) == 1 and children[0].is_dir():
103 + return children[0]
104 + return target
105 +
106 +
107 +def build_import_plan(
108 + source: Path,
109 + dest_root: Path,
110 + *,
111 + namespace: Optional[str] = None,
112 +) -> Tuple[List[ImportPlanItem], Path]:
113 + """
114 + Build a copy plan for importing skills from a source folder.
115 +
116 + Returns: (plan_items, source_root_dir_used_for_scan)
117 + """
118 + source_dir = source
119 + roots = _candidate_skill_roots(source_dir)
120 + plan: List[ImportPlanItem] = []
121 + ns = (namespace or _derive_namespace(source)).strip()
122 + dest_ns_root = dest_root / ns
123 +
124 + for root in roots:
125 + for skill_md in discover_skill_md_files(root):
126 + skill_dir = skill_md.parent
127 + # Skip if the skill dir is already inside destination (prevents recursive import)
128 + if _is_within(skill_dir, dest_root):
129 + continue
130 + try:
131 + rel = skill_dir.resolve().relative_to(root.resolve())
132 + except Exception:
133 + # If relative fails due to symlink oddities, just use leaf folder name
134 + rel = Path(skill_dir.name)
135 + dest_dir = dest_ns_root / rel
136 + plan.append(ImportPlanItem(src_root=root, src_skill_dir=skill_dir, dest_skill_dir=dest_dir))
137 +
138 + # Deduplicate by destination path (keep first occurrence)
139 + seen_dest = set()
140 + deduped: List[ImportPlanItem] = []
141 + for item in plan:
142 + key = str(item.dest_skill_dir.resolve())
143 + if key in seen_dest:
144 + continue
145 + seen_dest.add(key)
146 + deduped.append(item)
147 +
148 + return deduped, roots[0]
149 +
150 +
151 +def _resolve_conflict(dest: Path, policy: ConflictPolicy) -> Tuple[Path, bool]:
152 + """
153 + Returns (final_dest_path, should_copy).
154 + """
155 + if not dest.exists():
156 + return dest, True
157 +
158 + if policy == "skip":
159 + return dest, False
160 +
161 + if policy == "overwrite":
162 + shutil.rmtree(dest)
163 + return dest, True
164 +
165 + # rename
166 + i = 2
167 + while True:
168 + candidate = dest.with_name(f"{dest.name}_{i}")
169 + if not candidate.exists():
170 + return candidate, True
171 + i += 1
172 +
173 +
174 +def get_project_skills_folder(project_name: str) -> Path:
175 + """Get the skills folder path for a project."""
176 + from python.helpers.projects import get_project_meta_folder
177 + return Path(get_project_meta_folder(project_name, PROJECT_SKILLS_DIR))
178 +
179 +
180 +def import_skills(
181 + source_path: str,
182 + *,
183 + dest_subdir: DestSubdir = "shared",
184 + namespace: Optional[str] = None,
185 + conflict: ConflictPolicy = "skip",
186 + dry_run: bool = False,
187 + project_name: Optional[str] = None,
188 +) -> ImportResult:
189 + """
190 + Import external Skills into skills/<dest_subdir>/<namespace>/...
191 +
192 + If dest_subdir is "project", imports into the project's .a0proj/skills/ folder.
193 +
194 + - source_path can be a directory or a .zip file
195 + - Uses heuristics to detect the Skills root(s)
196 + - Copies each skill folder (parent of SKILL.md) as-is
197 + """
198 + src = Path(source_path).expanduser()
199 + if not src.is_absolute():
200 + src = (Path.cwd() / src).resolve()
201 +
202 + if not src.exists():
203 + raise FileNotFoundError(f"Source not found: {src}")
204 +
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("skills", dest_subdir))
212 + dest_root.mkdir(parents=True, exist_ok=True)
213 +
214 + extracted_root: Optional[Path] = None
215 + source_dir: Path
216 + if src.is_file() and src.suffix.lower() == ".zip":
217 + extracted_root = _unzip_to_temp_dir(src)
218 + source_dir = extracted_root
219 + elif src.is_dir():
220 + source_dir = src
221 + else:
222 + raise ValueError("Source must be a directory or a .zip file")
223 +
224 + ns = (namespace or _derive_namespace(src)).strip()
225 + if not ns:
226 + ns = "import"
227 +
228 + plan, root_used = build_import_plan(source_dir, dest_root, namespace=ns)
229 + imported: List[Path] = []
230 + skipped: List[Path] = []
231 +
232 + for item in plan:
233 + final_dest, should_copy = _resolve_conflict(item.dest_skill_dir, conflict)
234 + if not should_copy:
235 + skipped.append(item.dest_skill_dir)
236 + continue
237 + if dry_run:
238 + imported.append(final_dest)
239 + continue
240 + final_dest.parent.mkdir(parents=True, exist_ok=True)
241 + shutil.copytree(item.src_skill_dir, final_dest)
242 + imported.append(final_dest)
243 +
244 + return ImportResult(
245 + imported=imported,
246 + skipped=skipped,
247 + source_root=root_used,
248 + destination_root=dest_root,
249 + namespace=ns,
250 + )
251 +
python/tools/skills_tool.py new
+351
@@ -0,0 +1,351 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +import re
5 +import shlex
6 +from pathlib import Path
7 +from typing import Any, Dict, List
8 +
9 +from python.helpers.tool import Tool, Response
10 +from python.helpers import files
11 +from python.helpers import skills as skills_helper
12 +
13 +
14 +class SkillsTool(Tool):
15 + """
16 + Manage and use SKILL.md-based Skills (Anthropic open standard).
17 +
18 + Methods (tool_args.method):
19 + - list
20 + - search (query)
21 + - load (skill_name)
22 + - read_file (skill_name, file_path)
23 + - execute_script (skill_name, script_path, script_args)
24 + """
25 +
26 + async def execute(self, **kwargs) -> Response:
27 + method = (
28 + (kwargs.get("method") or self.args.get("method") or self.method or "")
29 + .strip()
30 + .lower()
31 + )
32 +
33 + try:
34 + if method == "list":
35 + return Response(message=self._list(), break_loop=False)
36 + if method == "search":
37 + query = str(kwargs.get("query") or "").strip()
38 + return Response(message=self._search(query), break_loop=False)
39 + if method == "load":
40 + skill_name = str(kwargs.get("skill_name") or "").strip()
41 + return Response(message=self._load(skill_name), break_loop=False)
42 + if method == "read_file":
43 + skill_name = str(kwargs.get("skill_name") or "").strip()
44 + file_path = str(kwargs.get("file_path") or "").strip()
45 + return Response(message=self._read_file(skill_name, file_path), break_loop=False)
46 + if method == "execute_script":
47 + skill_name = str(kwargs.get("skill_name") or "").strip()
48 + script_path = str(kwargs.get("script_path") or "").strip()
49 + script_args = kwargs.get("script_args") or {}
50 + if not isinstance(script_args, dict):
51 + script_args = {}
52 + return await self._execute_script(skill_name, script_path, script_args)
53 +
54 + return Response(
55 + message=(
56 + "Error: missing/invalid 'method'. Supported methods: "
57 + "list, search, load, read_file, execute_script."
58 + ),
59 + break_loop=False,
60 + )
61 + except Exception as e: # keep tool robust; return error instead of crashing loop
62 + return Response(message=f"Error in skills_tool: {e}", break_loop=False)
63 +
64 + def _list(self) -> str:
65 + skills = skills_helper.list_skills(include_content=False, dedupe=True)
66 + if not skills:
67 + return "No skills found. Expected SKILL.md files under: skills/{custom,builtin,shared}."
68 +
69 + # Stable output: sort by name
70 + skills_sorted = sorted(skills, key=lambda s: (s.name.lower(), s.source))
71 +
72 + lines: List[str] = []
73 + lines.append(f"Available skills ({len(skills_sorted)}):")
74 + for s in skills_sorted:
75 + tags = f" tags={','.join(s.tags)}" if s.tags else ""
76 + ver = f" v{s.version}" if s.version else ""
77 + desc = (s.description or "").strip()
78 + if len(desc) > 200:
79 + desc = desc[:200].rstrip() + "…"
80 + lines.append(f"- {s.name}{ver} [{s.source}]{tags}: {desc}")
81 + lines.append("")
82 + lines.append("Tip: use skills_tool method=search or method=load for details.")
83 + return "\n".join(lines)
84 +
85 + def _search(self, query: str) -> str:
86 + if not query:
87 + return "Error: 'query' is required for method=search."
88 +
89 + results = skills_helper.search_skills(query, limit=25)
90 + if not results:
91 + return f"No skills matched query: {query!r}"
92 +
93 + lines: List[str] = []
94 + lines.append(f"Skills matching {query!r} ({len(results)}):")
95 + for s in results:
96 + desc = (s.description or "").strip()
97 + if len(desc) > 200:
98 + desc = desc[:200].rstrip() + "…"
99 + lines.append(f"- {s.name} [{s.source}]: {desc}")
100 + lines.append("")
101 + lines.append("Tip: use skills_tool method=load skill_name=<name> to load full instructions.")
102 + return "\n".join(lines)
103 +
104 + def _load(self, skill_name: str) -> str:
105 + if not skill_name:
106 + return "Error: 'skill_name' is required for method=load."
107 +
108 + skill = skills_helper.find_skill(skill_name, include_content=True)
109 + if not skill:
110 + return f"Error: skill not found: {skill_name!r}. Try skills_tool method=list or method=search."
111 +
112 + # Enumerate files under the skill directory for progressive disclosure
113 + referenced_files = self._list_skill_files(skill.path, max_files=80)
114 + rel_skill_dir = Path(files.deabsolute_path(str(skill.path)))
115 +
116 + lines: List[str] = []
117 + lines.append(f"Skill: {skill.name}")
118 + lines.append(f"Source: {skill.source}")
119 + lines.append(f"Path: {rel_skill_dir}")
120 + if skill.version:
121 + lines.append(f"Version: {skill.version}")
122 + if skill.author:
123 + lines.append(f"Author: {skill.author}")
124 + if skill.license:
125 + lines.append(f"License: {skill.license}")
126 + if skill.tags:
127 + lines.append(f"Tags: {', '.join(skill.tags)}")
128 + if skill.allowed_tools:
129 + lines.append(f"Allowed tools: {', '.join(skill.allowed_tools)}")
130 + if skill.triggers:
131 + lines.append(f"Triggers: {', '.join(skill.triggers)}")
132 +
133 + lines.append("")
134 + if skill.description:
135 + lines.append("Description:")
136 + lines.append(skill.description.strip())
137 + lines.append("")
138 +
139 + lines.append("Content (SKILL.md body):")
140 + lines.append(skill.content.strip() or "(empty)")
141 + lines.append("")
142 +
143 + if referenced_files:
144 + lines.append("Files in skill directory (use skills_tool method=read_file to open):")
145 + for p in referenced_files:
146 + lines.append(f"- {p}")
147 + else:
148 + lines.append("No additional files found in skill directory.")
149 +
150 + return "\n".join(lines)
151 +
152 + def _read_file(self, skill_name: str, file_path: str) -> str:
153 + if not skill_name:
154 + return "Error: 'skill_name' is required for method=read_file."
155 + if not file_path:
156 + return "Error: 'file_path' is required for method=read_file."
157 +
158 + skill = skills_helper.find_skill(skill_name, include_content=False)
159 + if not skill:
160 + return f"Error: skill not found: {skill_name!r}."
161 +
162 + try:
163 + target = skills_helper.safe_path_within_dir(skill.path, file_path)
164 + except Exception as e:
165 + return f"Error: invalid file_path: {e}"
166 +
167 + if not target.exists() or not target.is_file():
168 + return f"Error: file not found: {file_path!r} (within skill {skill.name})"
169 +
170 + # Basic binary guard: if null byte present, do not dump
171 + content = target.read_bytes()
172 + if b"\x00" in content[:4096]:
173 + return f"Error: file appears to be binary; refusing to print raw bytes ({file_path})."
174 +
175 + text = content.decode("utf-8", errors="replace")
176 + return f"File: {file_path}\n\n{text}"
177 +
178 + async def _execute_script(
179 + self, skill_name: str, script_path: str, script_args: Dict[str, Any]
180 + ) -> Response:
181 + if not skill_name:
182 + return Response(message="Error: 'skill_name' is required for method=execute_script.", break_loop=False)
183 + if not script_path:
184 + return Response(message="Error: 'script_path' is required for method=execute_script.", break_loop=False)
185 +
186 + skill = skills_helper.find_skill(skill_name, include_content=False)
187 + if not skill:
188 + return Response(message=f"Error: skill not found: {skill_name!r}.", break_loop=False)
189 +
190 + try:
191 + script_abs = skills_helper.safe_path_within_dir(skill.path, script_path)
192 + except Exception as e:
193 + return Response(message=f"Error: invalid script_path: {e}", break_loop=False)
194 +
195 + if not script_abs.exists() or not script_abs.is_file():
196 + return Response(message=f"Error: script not found: {script_path!r} (within skill {skill.name})", break_loop=False)
197 +
198 + ext = script_abs.suffix.lower()
199 + runtime: str
200 + code: str
201 +
202 + # Use /a0 paths for remote (SSH) execution inside the container; use local absolute paths otherwise.
203 + if self.agent.config.code_exec_ssh_enabled:
204 + script_runtime_path = files.normalize_a0_path(str(script_abs))
205 + script_runtime_dir = files.normalize_a0_path(str(script_abs.parent))
206 + else:
207 + script_runtime_path = str(script_abs)
208 + script_runtime_dir = str(script_abs.parent)
209 +
210 + # Normalize args once for injection
211 + args_json = json.dumps(script_args, ensure_ascii=False)
212 +
213 + if ext == ".py":
214 + runtime = "python"
215 + code = (
216 + "import json, os, runpy\n"
217 + f"os.chdir({json.dumps(script_runtime_dir)})\n"
218 + f"_skill_args = json.loads({json.dumps(args_json)})\n"
219 + f"runpy.run_path({json.dumps(script_runtime_path)}, run_name='__main__', init_globals={{'_skill_args': _skill_args}})\n"
220 + )
221 + elif ext == ".js":
222 + runtime = "nodejs"
223 + code = (
224 + "const fs = require('fs');\n"
225 + "const path = require('path');\n"
226 + f"process.chdir({json.dumps(script_runtime_dir)});\n"
227 + f"const _skill_args = JSON.parse({json.dumps(args_json)});\n"
228 + f"const __skill_script = fs.readFileSync({json.dumps(script_runtime_path)}, 'utf8');\n"
229 + "eval(__skill_script);\n"
230 + )
231 + elif ext == ".sh":
232 + runtime = "terminal"
233 + env_parts: List[str] = []
234 + for k, v in (script_args or {}).items():
235 + key = re.sub(r"[^A-Za-z0-9_]", "_", str(k).upper())
236 + if not key:
237 + continue
238 + env_key = f"SKILL_ARG_{key}"
239 + env_parts.append(f"{env_key}={shlex.quote(str(v))}")
240 + env_prefix = " ".join(env_parts)
241 + cd_cmd = f"cd {shlex.quote(script_runtime_dir)}"
242 + run_cmd = f"bash {shlex.quote(script_runtime_path)}"
243 + if env_prefix:
244 + code = f"{cd_cmd} && {env_prefix} {run_cmd}"
245 + else:
246 + code = f"{cd_cmd} && {run_cmd}"
247 + else:
248 + return Response(
249 + message=f"Error: unsupported script type {ext!r}. Supported: .py, .js, .sh",
250 + break_loop=False,
251 + )
252 +
253 + # Delegate actual execution to code_execution_tool (sandboxed)
254 + from python.tools.code_execution_tool import CodeExecution
255 +
256 + cet = CodeExecution(
257 + agent=self.agent,
258 + name="code_execution_tool",
259 + method=None,
260 + args={
261 + "runtime": runtime,
262 + "code": code,
263 + "session": int(self.args.get("session", 0) or 0),
264 + },
265 + message=self.message,
266 + loop_data=self.loop_data,
267 + )
268 +
269 + resp = await cet.execute(**cet.args)
270 + # Wrap result to make it clear it was a skill script
271 + wrapped = (
272 + f"Executed script: {skill.name}/{script_path}\n"
273 + f"Runtime: {runtime}\n\n"
274 + f"{resp.message}"
275 + )
276 + return Response(message=wrapped, break_loop=False)
277 +
278 + def _list_skill_files(self, skill_dir: Path, *, max_files: int = 80) -> List[str]:
279 + if not skill_dir.exists():
280 + return []
281 +
282 + results: List[str] = []
283 +
284 + preferred_dirs = ["scripts", "references", "assets", "templates", "docs"]
285 +
286 + # 1) Root-level files (excluding SKILL.md)
287 + try:
288 + for p in sorted(skill_dir.iterdir(), key=lambda x: x.name):
289 + if len(results) >= max_files:
290 + return results
291 + if p.name.startswith("."):
292 + continue
293 + if p.is_file():
294 + if p.name == "SKILL.md":
295 + continue
296 + results.append(p.name)
297 + except Exception:
298 + pass
299 +
300 + # 2) Preferred optional directories (one level deep)
301 + for dname in preferred_dirs:
302 + dpath = skill_dir / dname
303 + if not dpath.exists() or not dpath.is_dir():
304 + continue
305 + try:
306 + for p in sorted(dpath.iterdir(), key=lambda x: x.name):
307 + if len(results) >= max_files:
308 + return results
309 + if p.name.startswith("."):
310 + continue
311 + if p.is_file():
312 + results.append(f"{dname}/{p.name}")
313 + elif p.is_dir():
314 + # Show one nested level (common in assets/templates/*)
315 + nested_added = False
316 + try:
317 + for sub in sorted(p.iterdir(), key=lambda x: x.name):
318 + if sub.name.startswith("."):
319 + continue
320 + if sub.is_file():
321 + results.append(f"{dname}/{p.name}/{sub.name}")
322 + nested_added = True
323 + break
324 + except Exception:
325 + pass
326 + if not nested_added:
327 + results.append(f"{dname}/{p.name}/")
328 + except Exception:
329 + continue
330 +
331 + # 3) Other directories (one level deep)
332 + try:
333 + for p in sorted(skill_dir.iterdir(), key=lambda x: x.name):
334 + if len(results) >= max_files:
335 + return results
336 + if p.name.startswith(".") or p.name in preferred_dirs:
337 + continue
338 + if p.is_dir():
339 + for sub in sorted(p.iterdir(), key=lambda x: x.name):
340 + if len(results) >= max_files:
341 + return results
342 + if sub.name.startswith("."):
343 + continue
344 + if sub.is_file():
345 + results.append(f"{p.name}/{sub.name}")
346 + except Exception:
347 + pass
348 +
349 + return results
350 +
351 +
requirements.txt
+2 -2
@@ -4,7 +4,7 @@ browser-use==0.5.11
4 docker==7.1.0
5 duckduckgo-search==6.1.12
6 faiss-cpu==1.11.0
7 -fastmcp==2.11.0
7 +fastmcp==2.3.4
8 fasta2a==0.5.0
9 flask[async]==3.0.3
10 flask-basicauth==0.2.0
@@ -19,7 +19,7 @@ langchain-unstructured[all-docs]==0.1.6
19 openai-whisper==20240930
20 lxml_html_clean==0.3.1
21 markdown==3.7
22 -mcp==1.12.4
22 +mcp==1.13.1
23 newspaper3k==0.2.8
24 paramiko==3.5.0
25 playwright==1.52.0
skills/shared/.gitkeep
skills/shared/claude-code-skill-factory-dev/generated-skills/prompt-factory/outputs/.gitkeep
webui/components/projects/project-edit-skills.html new
+92
@@ -0,0 +1,92 @@
1 +<html>
2 +<head>
3 + <title>Project Skills</title>
4 + <script type="module">
5 + import { store } from "/components/projects/projects-store.js";
6 + import { store as skillsStore } from "/components/settings/skills/skills-import-store.js";
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 + }">
20 + <template x-if="$store.projects && $store.projects.selectedProject">
21 + <div class="project-skills-section">
22 + <p class="skills-description">
23 + Import skills specific to this project. Project skills are stored in the project's
24 + <code>.a0proj/skills/</code> folder and are only available when this project is active.
25 + </p>
26 +
27 + <div class="skills-actions">
28 + <button type="button" class="button" @click="openSkillsImport()">
29 + Import Skills
30 + </button>
31 + </div>
32 +
33 + <div class="skills-info">
34 + <p>
35 + <strong>Project Skills Location:</strong><br>
36 + <code x-text="'usr/projects/' + $store.projects.selectedProject.name + '/.a0proj/skills/'"></code>
37 + </p>
38 + <p class="skills-note">
39 + Use the global Settings &gt; Skills tab for shared skills available to all projects.
40 + </p>
41 + </div>
42 + </div>
43 + </template>
44 + </div>
45 +
46 + <style>
47 + .project-skills-section {
48 + display: flex;
49 + flex-direction: column;
50 + gap: 1rem;
51 + }
52 +
53 + .skills-description {
54 + color: var(--color-text-secondary);
55 + font-size: 0.9rem;
56 + margin: 0;
57 + }
58 +
59 + .skills-actions {
60 + display: flex;
61 + gap: 0.5rem;
62 + }
63 +
64 + .skills-info {
65 + background: var(--color-bg-secondary);
66 + padding: 0.75rem;
67 + border-radius: 4px;
68 + font-size: 0.85rem;
69 + }
70 +
71 + .skills-info p {
72 + margin: 0 0 0.5rem 0;
73 + }
74 +
75 + .skills-info p:last-child {
76 + margin-bottom: 0;
77 + }
78 +
79 + .skills-note {
80 + color: var(--color-text-secondary);
81 + font-style: italic;
82 + }
83 +
84 + .skills-info code {
85 + background: var(--color-bg-tertiary);
86 + padding: 0.1rem 0.3rem;
87 + border-radius: 3px;
88 + font-size: 0.85em;
89 + }
90 + </style>
91 +</body>
92 +</html>
webui/components/projects/project-edit.html
+8
@@ -66,6 +66,14 @@
66 </x-component>
67 </div>
68
69 + <div class="project-detail">
70 + <div class="project-detail-header">
71 + <span class="projects-project-card-title">Skills</span>
72 + </div>
73 + <x-component path="projects/project-edit-skills.html">
74 + </x-component>
75 + </div>
76 +
77 <div class="buttons-container" style="margin: var(--spacing-md) var(--spacing-sm) var(--spacing-lg) var(--spacing-sm);">
78 <div class="buttons-left">
79 <button type="button" class="button cancel"
webui/components/settings/skills/import.html new
+221
@@ -0,0 +1,221 @@
1 +<html>
2 +<head>
3 + <title>Import Skills</title>
4 + <script type="module">
5 + import { store } from "/components/settings/skills/skills-import-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.skillsImportStore">
11 + <div x-init="$store.skillsImportStore.init()" x-destroy="$store.skillsImportStore.onClose()">
12 +
13 + <h3>Import Skills (SKILL.md)</h3>
14 +
15 + <div class="upload-section">
16 + <label for="skills-file" class="upload-label">
17 + Select Skills Pack (.zip)
18 + </label>
19 + <input type="file" id="skills-file" accept=".zip"
20 + @change="$store.skillsImportStore.handleFileUpload($event)">
21 + <div class="upload-hint">
22 + Upload a repository/archive that contains SKILL.md skill folders (see agentskills.io).
23 + </div>
24 + </div>
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"
30 + @change="$store.skillsImportStore.previewImport()">
31 + <option value="shared">Shared (recommended)</option>
32 + <option value="custom">Custom</option>
33 + <option value="project">Project</option>
34 + </select>
35 + </label>
36 +
37 + <label class="policy-label" x-show="$store.skillsImportStore.dest === 'project'">
38 + <span class="policy-label-text">Project:</span>
39 + <select x-model="$store.skillsImportStore.projectName" class="policy-dropdown"
40 + @change="$store.skillsImportStore.previewImport()">
41 + <option value="">Select a project...</option>
42 + <template x-for="project in $store.skillsImportStore.projects" :key="project.name">
43 + <option :value="project.name" x-text="project.title || project.name"></option>
44 + </template>
45 + </select>
46 + </label>
47 +
48 + <label class="policy-label">
49 + <span class="policy-label-text">Namespace:</span>
50 + <input class="text-input" type="text" placeholder="e.g. my-pack"
51 + x-model="$store.skillsImportStore.namespace"
52 + @change="$store.skillsImportStore.previewImport()">
53 + </label>
54 +
55 + <label class="policy-label">
56 + <span class="policy-label-text">Conflict policy:</span>
57 + <select x-model="$store.skillsImportStore.conflict" class="policy-dropdown"
58 + @change="$store.skillsImportStore.previewImport()">
59 + <option value="skip">Skip existing</option>
60 + <option value="rename">Rename (add _2, _3...)</option>
61 + <option value="overwrite">Overwrite existing</option>
62 + </select>
63 + </label>
64 +
65 + <div class="buttons">
66 + <button class="btn slim" @click="$store.skillsImportStore.previewImport()"
67 + :disabled="$store.skillsImportStore.loading">Preview</button>
68 + <button class="btn slim primary" @click="$store.skillsImportStore.performImport()"
69 + :disabled="$store.skillsImportStore.loading">Import</button>
70 + </div>
71 + </div>
72 +
73 + <div x-show="$store.skillsImportStore.loading" class="loading">
74 + <span x-text="$store.skillsImportStore.loadingMessage || 'Processing...'"></span>
75 + </div>
76 +
77 + <div x-show="$store.skillsImportStore.error" class="error">
78 + <span x-text="$store.skillsImportStore.error"></span>
79 + </div>
80 +
81 + <div x-show="$store.skillsImportStore.preview" class="preview">
82 + <h4>Preview</h4>
83 + <div class="preview-meta">
84 + <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>
88 + </div>
89 +
90 + <textarea class="preview-list" readonly
91 + x-text="($store.skillsImportStore.preview?.imported || []).join('\n')"></textarea>
92 + </div>
93 +
94 + <div x-show="$store.skillsImportStore.result" class="result">
95 + <h4>Import Complete</h4>
96 + <div class="preview-meta">
97 + <div>Imported: <span x-text="$store.skillsImportStore.result?.imported_count || 0"></span></div>
98 + <div>Skipped: <span x-text="$store.skillsImportStore.result?.skipped_count || 0"></span></div>
99 + </div>
100 + <div class="note">
101 + Skills are indexed automatically. If you don’t see them immediately, use the Restart button in the left pane.
102 + </div>
103 + </div>
104 +
105 + </div>
106 + </template>
107 + </div>
108 +
109 + <style>
110 + .upload-section {
111 + margin-bottom: 1rem;
112 + padding: 1rem;
113 + border: 2px dashed var(--color-border);
114 + border-radius: 4px;
115 + text-align: center;
116 + }
117 +
118 + .upload-label {
119 + display: block;
120 + margin-bottom: 0.5rem;
121 + font-weight: 600;
122 + }
123 +
124 + .upload-hint {
125 + margin-top: 0.5rem;
126 + font-size: 0.85rem;
127 + color: var(--color-secondary);
128 + }
129 +
130 + .options {
131 + margin: 1rem 0;
132 + padding: 0.75rem;
133 + background: var(--color-input);
134 + border: 1px solid var(--color-border);
135 + border-radius: 4px;
136 + }
137 +
138 + .policy-label {
139 + display: flex;
140 + align-items: center;
141 + gap: 0.5rem;
142 + margin: 0.5rem 0;
143 + }
144 +
145 + .policy-label-text {
146 + font-weight: 600;
147 + white-space: nowrap;
148 + width: 9rem;
149 + }
150 +
151 + .policy-dropdown, .text-input {
152 + flex: 1;
153 + padding: 0.5rem;
154 + border: 1px solid var(--color-border);
155 + border-radius: 4px;
156 + background: var(--color-bg-primary);
157 + color: var(--color-text-primary);
158 + font-size: 0.9rem;
159 + }
160 +
161 + .buttons {
162 + margin-top: 0.75rem;
163 + display: flex;
164 + gap: 0.5rem;
165 + }
166 +
167 + .loading {
168 + width: 100%;
169 + text-align: center;
170 + margin-top: 1rem;
171 + margin-bottom: 1rem;
172 + color: var(--color-secondary);
173 + }
174 +
175 + .error {
176 + color: var(--color-error);
177 + margin: 0.5rem 0;
178 + padding: 0.5rem;
179 + background: var(--color-error-bg);
180 + border-radius: 4px;
181 + }
182 +
183 + .preview, .result {
184 + margin-top: 1rem;
185 + padding: 0.75rem;
186 + background: var(--color-bg-primary);
187 + border: 1px solid var(--color-border);
188 + border-radius: 4px;
189 + }
190 +
191 + .preview-meta {
192 + display: grid;
193 + grid-template-columns: 1fr 1fr;
194 + gap: 0.25rem 1rem;
195 + margin-bottom: 0.5rem;
196 + color: var(--color-text-secondary);
197 + font-size: 0.9rem;
198 + }
199 +
200 + .preview-list {
201 + width: 100%;
202 + height: 12em;
203 + font-family: monospace;
204 + font-size: 0.85em;
205 + background: var(--color-bg-primary);
206 + color: var(--color-text-primary);
207 + border: 1px solid var(--color-border);
208 + border-radius: 4px;
209 + padding: 0.5em;
210 + resize: vertical;
211 + }
212 +
213 + .note {
214 + margin-top: 0.5rem;
215 + font-size: 0.9rem;
216 + color: var(--color-text-secondary);
217 + }
218 + </style>
219 +</body>
220 +</html>
221 +
webui/components/settings/skills/skills-import-store.js new
+180
@@ -0,0 +1,180 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const fetchApi = globalThis.fetchApi;
4 +
5 +function sanitizeNamespace(text) {
6 + if (!text) return "";
7 + return String(text)
8 + .trim()
9 + .replace(/[^a-zA-Z0-9._-]+/g, "_")
10 + .replace(/^_+|_+$/g, "");
11 +}
12 +
13 +const model = {
14 + loading: false,
15 + loadingMessage: "",
16 + error: "",
17 +
18 + skillsFile: null,
19 + dest: "shared", // shared|custom|project
20 + namespace: "",
21 + conflict: "skip", // skip|overwrite|rename
22 + projectName: "", // selected project name when dest is "project"
23 + projects: [], // available projects list
24 +
25 + preview: null,
26 + result: null,
27 +
28 + init() {
29 + this.resetState();
30 + this.loadProjects();
31 + },
32 +
33 + resetState() {
34 + this.loading = false;
35 + this.loadingMessage = "";
36 + this.error = "";
37 + this.preview = null;
38 + this.result = null;
39 + },
40 +
41 + onClose() {
42 + this.resetState();
43 + this.skillsFile = null;
44 + },
45 +
46 + async loadProjects() {
47 + try {
48 + const response = await fetchApi("/projects", {
49 + method: "POST",
50 + headers: { "Content-Type": "application/json" },
51 + body: JSON.stringify({ action: "list" }),
52 + });
53 + const data = await response.json();
54 + this.projects = data.ok ? (data.data || []) : [];
55 + } catch (e) {
56 + console.error("Failed to load projects:", e);
57 + this.projects = [];
58 + }
59 + },
60 +
61 + async handleFileUpload(event) {
62 + const file = event.target.files[0];
63 + if (!file) return;
64 +
65 + this.skillsFile = file;
66 + this.error = "";
67 + this.result = null;
68 + this.preview = null;
69 +
70 + // default namespace from file name (minus .zip)
71 + const base = file.name.replace(/\.zip$/i, "");
72 + if (!this.namespace) {
73 + this.namespace = sanitizeNamespace(base);
74 + } else {
75 + this.namespace = sanitizeNamespace(this.namespace);
76 + }
77 +
78 + await this.previewImport();
79 + },
80 +
81 + buildFormData() {
82 + const formData = new FormData();
83 + formData.append("skills_file", this.skillsFile);
84 + formData.append("ctxid", globalThis.getContext ? globalThis.getContext() : "");
85 + formData.append("dest", this.dest);
86 + formData.append("namespace", sanitizeNamespace(this.namespace));
87 + formData.append("conflict", this.conflict);
88 + if (this.dest === "project" && this.projectName) {
89 + formData.append("project_name", this.projectName);
90 + }
91 + return formData;
92 + },
93 +
94 + async previewImport() {
95 + if (!this.skillsFile) {
96 + this.error = "Please select a skills .zip file first";
97 + return;
98 + }
99 +
100 + if (this.dest === "project" && !this.projectName) {
101 + this.error = "Please select a project";
102 + return;
103 + }
104 +
105 + try {
106 + this.loading = true;
107 + this.loadingMessage = "Previewing skills import...";
108 + this.error = "";
109 + this.preview = null;
110 +
111 + const response = await fetchApi("/skills_import_preview", {
112 + method: "POST",
113 + body: this.buildFormData(),
114 + });
115 +
116 + const result = await response.json();
117 + if (!result.success) {
118 + this.error = result.error || "Preview failed";
119 + return;
120 + }
121 +
122 + this.preview = result;
123 + // normalize namespace (server may sanitize)
124 + if (result.namespace) this.namespace = result.namespace;
125 + } catch (e) {
126 + this.error = `Preview error: ${e.message}`;
127 + } finally {
128 + this.loading = false;
129 + this.loadingMessage = "";
130 + }
131 + },
132 +
133 + async performImport() {
134 + if (!this.skillsFile) {
135 + this.error = "Please select a skills .zip file first";
136 + return;
137 + }
138 +
139 + if (this.dest === "project" && !this.projectName) {
140 + this.error = "Please select a project";
141 + return;
142 + }
143 +
144 + try {
145 + this.loading = true;
146 + this.loadingMessage = "Importing skills...";
147 + this.error = "";
148 + this.result = null;
149 +
150 + const response = await fetchApi("/skills_import", {
151 + method: "POST",
152 + body: this.buildFormData(),
153 + });
154 +
155 + const result = await response.json();
156 + if (!result.success) {
157 + this.error = result.error || "Import failed";
158 + return;
159 + }
160 +
161 + this.result = result;
162 + this.preview = result; // keep last info visible
163 + if (window.toastFrontendInfo) {
164 + window.toastFrontendInfo(
165 + `Imported ${result.imported_count} skill folder(s)`,
166 + "Skills Import"
167 + );
168 + }
169 + } catch (e) {
170 + this.error = `Import error: ${e.message}`;
171 + } finally {
172 + this.loading = false;
173 + this.loadingMessage = "";
174 + }
175 + },
176 +};
177 +
178 +const store = createStore("skillsImportStore", model);
179 +export { store };
180 +
webui/index.html
+21 -2
@@ -142,7 +142,6 @@
142 <div class="sidebar-overlay" :class="{'visible': $store.sidebar.isOpen && $store.sidebar.isMobile()}" @click="$store.sidebar.close()"></div>
143 </template>
144 </div>
145 - </template>
145 <!-- Left Sidebar (Header Icons, Quick Actions, Tabs, Chats, Tasks) -->
146 <x-component path="sidebar/left-sidebar.html"></x-component>
147
@@ -206,13 +205,15 @@
205 @click="switchTab('developer')" title="Developer">Developer</div>
206 <div class="settings-tab" :class="{'active': activeTab === 'scheduler'}"
207 @click="switchTab('scheduler')" title="Task Scheduler">Task Scheduler</div>
208 + <div class="settings-tab" :class="{'active': activeTab === 'skills'}"
209 + @click="switchTab('skills')" title="Skills">Skills</div>
210 <div class="settings-tab" :class="{'active': activeTab === 'backup'}"
211 @click="switchTab('backup')" title="Backup & Restore">Backup & Restore</div>
212 </div>
213 </div>
214
215 <!-- Display settings sections for agent, external, developer, mcp, backup tabs -->
215 - <div id="settings-sections" x-show="activeTab !== 'scheduler'">
216 + <div id="settings-sections" x-show="activeTab !== 'scheduler' && activeTab !== 'skills'">
217 <nav>
218 <ul>
219 <template x-for="(section, index) in filteredSections" :key="section.title">
@@ -1218,6 +1219,24 @@
1219 </div>
1220 </div>
1221 </div>
1222 +
1223 + <!-- Skills Tab Content -->
1224 + <div id="skills-tab-content" x-show="activeTab === 'skills'" x-cloak>
1225 + <nav>
1226 + <ul>
1227 + <li>
1228 + <a href="#section-skills-import">
1229 + <img src="/public/skills.svg" alt="Skills">
1230 + <span>Import Skills</span>
1231 + </a>
1232 + </li>
1233 + </ul>
1234 + </nav>
1235 +
1236 + <div id="section-skills-import" class="section">
1237 + <x-component path="settings/skills/import.html" />
1238 + </div>
1239 + </div>
1240 </div>
1241
1242 <div class="modal-footer">
webui/js/settings.js
+2
@@ -283,6 +283,8 @@ const settingsModalProxy = {
283 openModal("settings/external/api-examples.html");
284 } else if (field.id === "memory_dashboard") {
285 openModal("settings/memory/memory-dashboard.html");
286 + } else if (field.id === "skills_import") {
287 + openModal("settings/skills/import.html");
288 }
289 }
290 };
webui/public/skills.svg new
+4
@@ -0,0 +1,4 @@
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