- 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/<namespace>/...</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 > 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