Skip to content

Commit c1de265

Browse files
committed
feat(skills): mark sandbox preset skills readonly
expose skill source metadata and sandbox cache status in the skills API response so the dashboard can distinguish local, sandbox-only, and synced skills. prevent enabling, disabling, or deleting sandbox-only preset skills in both backend guards and UI actions to avoid invalid local operations. add source badges, discovery-pending hinting for sandbox runtime, and new i18n strings for source labels and readonly warnings.
1 parent 13c8fa3 commit c1de265

6 files changed

Lines changed: 286 additions & 33 deletions

File tree

astrbot/core/skills/skill_manager.py

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class SkillInfo:
3232
description: str
3333
path: str
3434
active: bool
35+
source_type: str = "local_only"
36+
source_label: str = "local"
37+
local_exists: bool = True
38+
sandbox_exists: bool = False
3539

3640

3741
def _parse_frontmatter_description(text: str) -> str:
@@ -164,6 +168,7 @@ def _load_sandbox_skills_cache(self) -> dict:
164168
return {
165169
"version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)),
166170
"skills": skills,
171+
"updated_at": data.get("updated_at"),
167172
}
168173
except Exception:
169174
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
@@ -198,6 +203,17 @@ def set_sandbox_skills_cache(self, skills: list[dict]) -> None:
198203
}
199204
self._save_sandbox_skills_cache(cache)
200205

206+
def get_sandbox_skills_cache_status(self) -> dict[str, object]:
207+
cache = self._load_sandbox_skills_cache()
208+
skills = cache.get("skills", [])
209+
count = len(skills) if isinstance(skills, list) else 0
210+
return {
211+
"exists": os.path.exists(self.sandbox_skills_cache_path),
212+
"ready": count > 0,
213+
"count": count,
214+
"updated_at": cache.get("updated_at"),
215+
}
216+
201217
def list_skills(
202218
self,
203219
*,
@@ -217,15 +233,18 @@ def list_skills(
217233
skills_by_name: dict[str, SkillInfo] = {}
218234

219235
sandbox_cached_paths: dict[str, str] = {}
220-
if runtime == "sandbox":
221-
cache_for_paths = self._load_sandbox_skills_cache()
222-
for item in cache_for_paths.get("skills", []):
223-
if not isinstance(item, dict):
224-
continue
225-
name = str(item.get("name", "") or "").strip()
226-
path = str(item.get("path", "") or "").strip().replace("\\", "/")
227-
if name and path and _SKILL_NAME_RE.match(name):
228-
sandbox_cached_paths[name] = path
236+
sandbox_cached_descriptions: dict[str, str] = {}
237+
cache_for_paths = self._load_sandbox_skills_cache()
238+
for item in cache_for_paths.get("skills", []):
239+
if not isinstance(item, dict):
240+
continue
241+
name = str(item.get("name", "") or "").strip()
242+
path = str(item.get("path", "") or "").strip().replace("\\", "/")
243+
if not name or not _SKILL_NAME_RE.match(name):
244+
continue
245+
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
246+
if path:
247+
sandbox_cached_paths[name] = path
229248

230249
for entry in sorted(Path(self.skills_root).iterdir()):
231250
if not entry.is_dir():
@@ -246,6 +265,11 @@ def list_skills(
246265
description = _parse_frontmatter_description(content)
247266
except Exception:
248267
description = ""
268+
sandbox_exists = (
269+
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
270+
)
271+
source_type = "both" if sandbox_exists else "local_only"
272+
source_label = "synced" if sandbox_exists else "local"
249273
if runtime == "sandbox" and show_sandbox_path:
250274
path_str = sandbox_cached_paths.get(skill_name) or (
251275
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
@@ -258,6 +282,10 @@ def list_skills(
258282
description=description,
259283
path=path_str,
260284
active=active,
285+
source_type=source_type,
286+
source_label=source_label,
287+
local_exists=True,
288+
sandbox_exists=sandbox_exists,
261289
)
262290

263291
if runtime == "sandbox":
@@ -278,22 +306,22 @@ def list_skills(
278306
modified = True
279307
if active_only and not active:
280308
continue
281-
description = str(item.get("description", "") or "")
309+
description = sandbox_cached_descriptions.get(skill_name, "")
282310
if show_sandbox_path:
283-
path_str = (
284-
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
285-
)
311+
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
286312
else:
287-
path_str = str(item.get("path", "") or "")
313+
path_str = sandbox_cached_paths.get(skill_name, "")
288314
if not path_str:
289-
path_str = (
290-
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
291-
)
315+
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
292316
skills_by_name[skill_name] = SkillInfo(
293317
name=skill_name,
294318
description=description,
295319
path=path_str.replace("\\", "/"),
296320
active=active,
321+
source_type="sandbox_only",
322+
source_label="sandbox_preset",
323+
local_exists=False,
324+
sandbox_exists=True,
297325
)
298326

299327
if modified:
@@ -302,7 +330,27 @@ def list_skills(
302330

303331
return [skills_by_name[name] for name in sorted(skills_by_name)]
304332

333+
def is_sandbox_only_skill(self, name: str) -> bool:
334+
skill_dir = Path(self.skills_root) / name
335+
skill_md_exists = (skill_dir / "SKILL.md").exists()
336+
if skill_md_exists:
337+
return False
338+
cache = self._load_sandbox_skills_cache()
339+
skills = cache.get("skills", [])
340+
if not isinstance(skills, list):
341+
return False
342+
for item in skills:
343+
if not isinstance(item, dict):
344+
continue
345+
if str(item.get("name", "")).strip() == name:
346+
return True
347+
return False
348+
305349
def set_skill_active(self, name: str, active: bool) -> None:
350+
if self.is_sandbox_only_skill(name):
351+
raise PermissionError(
352+
"Sandbox preset skill cannot be enabled/disabled from local skill management."
353+
)
306354
config = self._load_config()
307355
config.setdefault("skills", {})
308356
config["skills"][name] = {"active": bool(active)}
@@ -318,8 +366,7 @@ def _remove_skill_from_sandbox_cache(self, name: str) -> None:
318366
item
319367
for item in skills
320368
if not (
321-
isinstance(item, dict)
322-
and str(item.get("name", "")).strip() == name
369+
isinstance(item, dict) and str(item.get("name", "")).strip() == name
323370
)
324371
]
325372

@@ -328,6 +375,11 @@ def _remove_skill_from_sandbox_cache(self, name: str) -> None:
328375
self._save_sandbox_skills_cache(cache)
329376

330377
def delete_skill(self, name: str) -> None:
378+
if self.is_sandbox_only_skill(name):
379+
raise PermissionError(
380+
"Sandbox preset skill cannot be deleted from local skill management."
381+
)
382+
331383
skill_dir = Path(self.skills_root) / name
332384
if skill_dir.exists():
333385
shutil.rmtree(skill_dir)

astrbot/dashboard/routes/skills.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import os
2+
import re
3+
import shutil
24
import traceback
35
from collections.abc import Awaitable, Callable
6+
from pathlib import Path
47
from typing import Any
58

6-
from quart import request
9+
from quart import request, send_file
710

811
from astrbot.core import DEMO_MODE, logger
912
from astrbot.core.computer.computer_client import (
@@ -37,13 +40,17 @@ def _to_bool(value: Any, default: bool = False) -> bool:
3740
return bool(value)
3841

3942

43+
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
44+
45+
4046
class SkillsRoute(Route):
4147
def __init__(self, context: RouteContext, core_lifecycle) -> None:
4248
super().__init__(context)
4349
self.core_lifecycle = core_lifecycle
4450
self.routes = {
4551
"/skills": ("GET", self.get_skills),
4652
"/skills/upload": ("POST", self.upload_skill),
53+
"/skills/download": ("GET", self.download_skill),
4754
"/skills/update": ("POST", self.update_skill),
4855
"/skills/delete": ("POST", self.delete_skill),
4956
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
@@ -116,14 +123,17 @@ async def get_skills(self):
116123
"provider_settings", {}
117124
)
118125
runtime = provider_settings.get("computer_use_runtime", "local")
119-
skills = SkillManager().list_skills(
126+
skill_mgr = SkillManager()
127+
skills = skill_mgr.list_skills(
120128
active_only=False, runtime=runtime, show_sandbox_path=False
121129
)
122130
return (
123131
Response()
124132
.ok(
125133
{
126134
"skills": [skill.__dict__ for skill in skills],
135+
"runtime": runtime,
136+
"sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(),
127137
}
128138
)
129139
.__dict__
@@ -178,6 +188,53 @@ async def upload_skill(self):
178188
except Exception:
179189
logger.warning(f"Failed to remove temp skill file: {temp_path}")
180190

191+
async def download_skill(self):
192+
try:
193+
name = str(request.args.get("name") or "").strip()
194+
if not name:
195+
return Response().error("Missing skill name").__dict__
196+
if not _SKILL_NAME_RE.match(name):
197+
return Response().error("Invalid skill name").__dict__
198+
199+
skill_mgr = SkillManager()
200+
if skill_mgr.is_sandbox_only_skill(name):
201+
return (
202+
Response()
203+
.error(
204+
"Sandbox preset skill cannot be downloaded from local skill files."
205+
)
206+
.__dict__
207+
)
208+
209+
skill_dir = Path(skill_mgr.skills_root) / name
210+
skill_md = skill_dir / "SKILL.md"
211+
if not skill_dir.is_dir() or not skill_md.exists():
212+
return Response().error("Local skill not found").__dict__
213+
214+
export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
215+
export_dir.mkdir(parents=True, exist_ok=True)
216+
zip_base = export_dir / name
217+
zip_path = zip_base.with_suffix(".zip")
218+
if zip_path.exists():
219+
zip_path.unlink()
220+
221+
shutil.make_archive(
222+
str(zip_base),
223+
"zip",
224+
root_dir=str(skill_mgr.skills_root),
225+
base_dir=name,
226+
)
227+
228+
return await send_file(
229+
str(zip_path),
230+
as_attachment=True,
231+
attachment_filename=f"{name}.zip",
232+
conditional=True,
233+
)
234+
except Exception as e:
235+
logger.error(traceback.format_exc())
236+
return Response().error(str(e)).__dict__
237+
181238
async def update_skill(self):
182239
if DEMO_MODE:
183240
return (

0 commit comments

Comments
 (0)