mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-17 01:49:15 +08:00
feat: support global Agent skills
This commit is contained in:
@@ -27,6 +27,7 @@ DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
|
||||
SANDBOX_SKILLS_ROOT = "skills"
|
||||
SANDBOX_WORKSPACE_ROOT = "/workspace"
|
||||
WORKSPACE_SKILLS_ROOT = "skills"
|
||||
AGENT_SKILLS_ROOT = ".agent/skills"
|
||||
WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS = 64 * 1024
|
||||
_SANDBOX_SKILLS_CACHE_VERSION = 1
|
||||
|
||||
@@ -42,6 +43,15 @@ def _default_sandbox_skill_path(name: str) -> str:
|
||||
return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md"
|
||||
|
||||
|
||||
def _default_global_skills_root() -> str:
|
||||
"""Return the default global Agent skills directory.
|
||||
|
||||
Returns:
|
||||
Absolute path to the current user's global Agent skills root.
|
||||
"""
|
||||
return (Path.home() / ".agent" / "skills").as_posix()
|
||||
|
||||
|
||||
def _normalize_cached_sandbox_skill_path(name: str, path: str) -> str:
|
||||
normalized = str(path or "").strip().replace("\\", "/")
|
||||
if not normalized:
|
||||
@@ -148,6 +158,27 @@ def _parse_frontmatter_description(text: str) -> str:
|
||||
return description.strip()
|
||||
|
||||
|
||||
def _read_skill_description(skill_md: Path, *, max_chars: int | None = None) -> str:
|
||||
"""Read a skill description from a SKILL.md frontmatter block.
|
||||
|
||||
Args:
|
||||
skill_md: Path to the skill markdown file.
|
||||
max_chars: Optional maximum number of characters to read.
|
||||
|
||||
Returns:
|
||||
The parsed description, or an empty string if it cannot be read.
|
||||
"""
|
||||
try:
|
||||
if max_chars is None:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
else:
|
||||
with skill_md.open(encoding="utf-8") as f:
|
||||
content = f.read(max_chars)
|
||||
return _parse_frontmatter_description(content)
|
||||
except (OSError, UnicodeError):
|
||||
return ""
|
||||
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
|
||||
@@ -289,9 +320,11 @@ class SkillManager:
|
||||
self,
|
||||
skills_root: str | None = None,
|
||||
plugins_root: str | None = None,
|
||||
global_skills_root: str | None = None,
|
||||
) -> None:
|
||||
self.skills_root = skills_root or get_astrbot_skills_path()
|
||||
self.plugins_root = plugins_root or get_astrbot_plugin_path()
|
||||
self.global_skills_root = global_skills_root or _default_global_skills_root()
|
||||
data_path = Path(get_astrbot_data_path())
|
||||
self.config_path = str(data_path / SKILLS_CONFIG_FILENAME)
|
||||
self.sandbox_skills_cache_path = str(data_path / SANDBOX_SKILLS_CACHE_FILENAME)
|
||||
@@ -339,6 +372,35 @@ class SkillManager:
|
||||
return skill_dir
|
||||
return None
|
||||
|
||||
def _get_global_skill_dir(self, name: str) -> Path | None:
|
||||
"""Return the global Agent skill directory for a skill name.
|
||||
|
||||
Args:
|
||||
name: Skill directory name to resolve.
|
||||
|
||||
Returns:
|
||||
The resolved global skill directory, or None if it is unavailable.
|
||||
"""
|
||||
if not _SKILL_NAME_RE.match(str(name or "")):
|
||||
return None
|
||||
|
||||
skills_root = Path(self.global_skills_root).expanduser()
|
||||
skill_dir = skills_root / name
|
||||
skill_md = _normalize_skill_markdown_path(skill_dir, rename_legacy=False)
|
||||
if skill_md is None or skill_md.name != "SKILL.md":
|
||||
return None
|
||||
try:
|
||||
resolved_skills_root = skills_root.resolve(strict=True)
|
||||
resolved_skill_dir = skill_dir.resolve(strict=True)
|
||||
resolved_skill_md = skill_md.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
if not resolved_skill_dir.is_relative_to(resolved_skills_root):
|
||||
return None
|
||||
if not resolved_skill_md.is_relative_to(resolved_skill_dir):
|
||||
return None
|
||||
return resolved_skill_dir
|
||||
|
||||
def list_workspace_skills(
|
||||
self, workspace_root: str | Path | None
|
||||
) -> list[SkillInfo]:
|
||||
@@ -354,57 +416,56 @@ class SkillManager:
|
||||
return []
|
||||
|
||||
raw_workspace_root = Path(workspace_root)
|
||||
skills_root = raw_workspace_root / WORKSPACE_SKILLS_ROOT
|
||||
if not skills_root.is_dir():
|
||||
return []
|
||||
workspace_skill_roots = (
|
||||
raw_workspace_root / AGENT_SKILLS_ROOT,
|
||||
raw_workspace_root / WORKSPACE_SKILLS_ROOT,
|
||||
)
|
||||
|
||||
try:
|
||||
resolved_workspace_root = raw_workspace_root.resolve(strict=True)
|
||||
resolved_skills_root = skills_root.resolve(strict=True)
|
||||
if not resolved_skills_root.is_relative_to(resolved_workspace_root):
|
||||
return []
|
||||
skill_dirs = sorted(
|
||||
resolved_skills_root.iterdir(), key=lambda item: item.name
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
skills_by_name: dict[str, SkillInfo] = {}
|
||||
for skills_root in workspace_skill_roots:
|
||||
if not skills_root.is_dir():
|
||||
continue
|
||||
|
||||
skills: list[SkillInfo] = []
|
||||
for skill_dir in skill_dirs:
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_name = skill_dir.name
|
||||
if not _SKILL_NAME_RE.match(skill_name):
|
||||
continue
|
||||
try:
|
||||
entry_names = {entry.name for entry in skill_dir.iterdir()}
|
||||
resolved_workspace_root = raw_workspace_root.resolve(strict=True)
|
||||
resolved_skills_root = skills_root.resolve(strict=True)
|
||||
if not resolved_skills_root.is_relative_to(resolved_workspace_root):
|
||||
continue
|
||||
skill_dirs = sorted(
|
||||
resolved_skills_root.iterdir(), key=lambda item: item.name
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
if "SKILL.md" not in entry_names:
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
resolved_skill_md = skill_md.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if not resolved_skill_md.is_relative_to(resolved_skills_root):
|
||||
continue
|
||||
for skill_dir in skill_dirs:
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_name = skill_dir.name
|
||||
if skill_name in skills_by_name or not _SKILL_NAME_RE.match(skill_name):
|
||||
continue
|
||||
try:
|
||||
entry_names = {entry.name for entry in skill_dir.iterdir()}
|
||||
except OSError:
|
||||
continue
|
||||
if "SKILL.md" not in entry_names:
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
|
||||
description = ""
|
||||
try:
|
||||
with resolved_skill_md.open(encoding="utf-8") as f:
|
||||
content = f.read(WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS)
|
||||
description = _parse_frontmatter_description(content)
|
||||
except (OSError, UnicodeError):
|
||||
description = ""
|
||||
try:
|
||||
resolved_skill_md = skill_md.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if not resolved_skill_md.is_relative_to(resolved_skills_root):
|
||||
continue
|
||||
|
||||
skills.append(
|
||||
SkillInfo(
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
description=_read_skill_description(
|
||||
resolved_skill_md,
|
||||
max_chars=WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS,
|
||||
),
|
||||
path=resolved_skill_md.as_posix(),
|
||||
active=True,
|
||||
source_type="workspace",
|
||||
@@ -412,9 +473,8 @@ class SkillManager:
|
||||
local_exists=True,
|
||||
readonly=True,
|
||||
)
|
||||
)
|
||||
|
||||
return skills
|
||||
return [skills_by_name[name] for name in sorted(skills_by_name)]
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if not os.path.exists(self.config_path):
|
||||
@@ -523,47 +583,69 @@ class SkillManager:
|
||||
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
|
||||
sandbox_cached_paths[name] = path
|
||||
|
||||
for entry in sorted(Path(self.skills_root).iterdir()):
|
||||
if not entry.is_dir():
|
||||
skill_roots = (
|
||||
(Path(self.skills_root), "local_only", "local", False, True),
|
||||
(
|
||||
Path(self.global_skills_root).expanduser(),
|
||||
"global",
|
||||
"global",
|
||||
True,
|
||||
False,
|
||||
),
|
||||
)
|
||||
for (
|
||||
skills_root,
|
||||
root_source_type,
|
||||
root_source_label,
|
||||
readonly,
|
||||
rename_legacy,
|
||||
) in skill_roots:
|
||||
if not skills_root.is_dir():
|
||||
continue
|
||||
skill_name = entry.name
|
||||
skill_md = _normalize_skill_markdown_path(entry)
|
||||
if skill_md is None:
|
||||
continue
|
||||
active = skill_configs.get(skill_name, {}).get("active", True)
|
||||
if skill_name not in skill_configs:
|
||||
skill_configs[skill_name] = {"active": active}
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
description = ""
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
description = _parse_frontmatter_description(content)
|
||||
except Exception:
|
||||
description = ""
|
||||
sandbox_exists = (
|
||||
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
|
||||
)
|
||||
source_type = "both" if sandbox_exists else "local_only"
|
||||
source_label = "synced" if sandbox_exists else "local"
|
||||
if runtime == "sandbox" and show_sandbox_path:
|
||||
path_str = sandbox_cached_paths.get(
|
||||
skill_name
|
||||
) or _default_sandbox_skill_path(skill_name)
|
||||
else:
|
||||
path_str = str(skill_md)
|
||||
path_str = path_str.replace("\\", "/")
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
path=path_str,
|
||||
active=active,
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
local_exists=True,
|
||||
sandbox_exists=sandbox_exists,
|
||||
)
|
||||
for entry in sorted(skills_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
skill_name = entry.name
|
||||
if skill_name in skills_by_name or not _SKILL_NAME_RE.match(skill_name):
|
||||
continue
|
||||
skill_md = _normalize_skill_markdown_path(
|
||||
entry,
|
||||
rename_legacy=rename_legacy,
|
||||
)
|
||||
if skill_md is None or (readonly and skill_md.name != "SKILL.md"):
|
||||
continue
|
||||
active = skill_configs.get(skill_name, {}).get("active", True)
|
||||
if skill_name not in skill_configs:
|
||||
skill_configs[skill_name] = {"active": active}
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
sandbox_exists = (
|
||||
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
|
||||
)
|
||||
source_type = root_source_type
|
||||
source_label = root_source_label
|
||||
if root_source_type == "local_only" and sandbox_exists:
|
||||
source_type = "both"
|
||||
source_label = "synced"
|
||||
if runtime == "sandbox" and show_sandbox_path:
|
||||
path_str = sandbox_cached_paths.get(
|
||||
skill_name
|
||||
) or _default_sandbox_skill_path(skill_name)
|
||||
else:
|
||||
path_str = str(skill_md)
|
||||
path_str = path_str.replace("\\", "/")
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=_read_skill_description(skill_md),
|
||||
path=path_str,
|
||||
active=active,
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
local_exists=True,
|
||||
sandbox_exists=sandbox_exists,
|
||||
readonly=readonly,
|
||||
)
|
||||
|
||||
for skill_name, plugin_name, skill_dir in self._iter_plugin_skill_dirs():
|
||||
if skill_name in skills_by_name:
|
||||
@@ -577,12 +659,6 @@ class SkillManager:
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
description = ""
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
description = _parse_frontmatter_description(content)
|
||||
except Exception:
|
||||
description = ""
|
||||
sandbox_exists = (
|
||||
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
|
||||
)
|
||||
@@ -594,7 +670,7 @@ class SkillManager:
|
||||
path_str = str(skill_md)
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
description=_read_skill_description(skill_md),
|
||||
path=path_str.replace("\\", "/"),
|
||||
active=active,
|
||||
source_type="plugin",
|
||||
@@ -652,6 +728,10 @@ class SkillManager:
|
||||
skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None
|
||||
if skill_md_exists:
|
||||
return False
|
||||
if self._get_global_skill_dir(name) is not None:
|
||||
return False
|
||||
if self._get_plugin_skill_dir(name) is not None:
|
||||
return False
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
@@ -666,6 +746,17 @@ class SkillManager:
|
||||
def is_plugin_skill(self, name: str) -> bool:
|
||||
return self._get_plugin_skill_dir(name) is not None
|
||||
|
||||
def is_global_skill(self, name: str) -> bool:
|
||||
"""Return whether a skill is provided by the global Agent directory.
|
||||
|
||||
Args:
|
||||
name: Skill name to check.
|
||||
|
||||
Returns:
|
||||
True if ``~/.agent/skills/<name>/SKILL.md`` is available.
|
||||
"""
|
||||
return self._get_global_skill_dir(name) is not None
|
||||
|
||||
def set_skill_active(self, name: str, active: bool) -> None:
|
||||
if self.is_sandbox_only_skill(name):
|
||||
raise PermissionError(
|
||||
@@ -699,14 +790,18 @@ class SkillManager:
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be deleted from local skill management."
|
||||
)
|
||||
if self.is_plugin_skill(name):
|
||||
raise PermissionError(
|
||||
"Plugin-provided skill cannot be deleted from local skill management."
|
||||
)
|
||||
|
||||
skill_dir = Path(self.skills_root) / name
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir)
|
||||
elif self.is_global_skill(name):
|
||||
raise PermissionError(
|
||||
"Global Agent skill cannot be deleted from local skill management."
|
||||
)
|
||||
elif self.is_plugin_skill(name):
|
||||
raise PermissionError(
|
||||
"Plugin-provided skill cannot be deleted from local skill management."
|
||||
)
|
||||
|
||||
# Ensure UI consistency even when there is no active sandbox session
|
||||
# to refresh cache from runtime side.
|
||||
|
||||
@@ -133,17 +133,42 @@ class SkillsService:
|
||||
"Sandbox preset skill cannot be opened from local skill files."
|
||||
)
|
||||
|
||||
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
|
||||
local_skill_dir = skills_root / skill_name
|
||||
if local_skill_dir.exists():
|
||||
skill_dir = local_skill_dir.resolve(strict=True)
|
||||
if not skill_dir.is_relative_to(skills_root):
|
||||
raise PermissionError("Invalid skill path")
|
||||
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||
return skill_dir
|
||||
|
||||
global_skill_dir = skill_mgr._get_global_skill_dir(skill_name)
|
||||
if global_skill_dir is not None:
|
||||
return global_skill_dir.resolve(strict=True)
|
||||
|
||||
plugin_skill_dir = skill_mgr._get_plugin_skill_dir(skill_name)
|
||||
if plugin_skill_dir is not None:
|
||||
return plugin_skill_dir.resolve(strict=True)
|
||||
|
||||
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
|
||||
skill_dir = (skills_root / skill_name).resolve(strict=True)
|
||||
if not skill_dir.is_relative_to(skills_root):
|
||||
raise PermissionError("Invalid skill path")
|
||||
if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists():
|
||||
raise FileNotFoundError("Local skill not found")
|
||||
return skill_dir
|
||||
raise FileNotFoundError("Local skill not found")
|
||||
|
||||
@staticmethod
|
||||
def is_skill_dir_readonly(skill_mgr: SkillManager, skill_dir: Path) -> bool:
|
||||
"""Return whether a resolved skill directory is externally managed.
|
||||
|
||||
Args:
|
||||
skill_mgr: Skill manager carrying the editable AstrBot skills root.
|
||||
skill_dir: Resolved skill directory.
|
||||
|
||||
Returns:
|
||||
True when the skill is outside the editable AstrBot skills root.
|
||||
"""
|
||||
try:
|
||||
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
|
||||
resolved_skill_dir = skill_dir.resolve(strict=True)
|
||||
except OSError:
|
||||
return True
|
||||
return not resolved_skill_dir.is_relative_to(skills_root)
|
||||
|
||||
@staticmethod
|
||||
def resolve_skill_relative_path(
|
||||
@@ -430,14 +455,18 @@ class SkillsService:
|
||||
raise SkillsServiceError(
|
||||
"Sandbox preset skill cannot be downloaded from local skill files."
|
||||
)
|
||||
if skill_mgr.is_plugin_skill(skill_name):
|
||||
raise SkillsServiceError(
|
||||
"Plugin-provided skill cannot be downloaded from local skill files."
|
||||
)
|
||||
|
||||
skill_dir = Path(skill_mgr.skills_root) / skill_name
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_dir.is_dir() or not skill_md.exists():
|
||||
if skill_mgr.is_global_skill(skill_name):
|
||||
raise SkillsServiceError(
|
||||
"Global Agent skill cannot be downloaded from local skill files."
|
||||
)
|
||||
if skill_mgr.is_plugin_skill(skill_name):
|
||||
raise SkillsServiceError(
|
||||
"Plugin-provided skill cannot be downloaded from local skill files."
|
||||
)
|
||||
raise SkillsServiceError("Local skill not found", status_code=404)
|
||||
|
||||
export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
|
||||
@@ -462,8 +491,9 @@ class SkillsService:
|
||||
|
||||
def list_skill_files(self, name: str, relative_path: str | None = "") -> dict:
|
||||
skill_name = str(name or "").strip()
|
||||
readonly = SkillManager().is_plugin_skill(skill_name)
|
||||
skill_mgr = SkillManager()
|
||||
skill_dir = self.resolve_local_skill_dir(skill_name)
|
||||
readonly = self.is_skill_dir_readonly(skill_mgr, skill_dir)
|
||||
target_dir = self.resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
@@ -507,7 +537,9 @@ class SkillsService:
|
||||
|
||||
def get_skill_file(self, name: str, relative_path: str | None = "SKILL.md") -> dict:
|
||||
skill_name = str(name or "").strip()
|
||||
skill_mgr = SkillManager()
|
||||
skill_dir = self.resolve_local_skill_dir(skill_name)
|
||||
readonly = self.is_skill_dir_readonly(skill_mgr, skill_dir)
|
||||
target_file = self.resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
@@ -530,7 +562,7 @@ class SkillsService:
|
||||
"path": self.skill_relative_path(skill_dir, target_file),
|
||||
"content": content,
|
||||
"size": size,
|
||||
"editable": not SkillManager().is_plugin_skill(skill_name),
|
||||
"editable": not readonly,
|
||||
}
|
||||
|
||||
def get_skill_file_from_dashboard_query(
|
||||
@@ -555,8 +587,9 @@ class SkillsService:
|
||||
raise SkillsServiceError("File content is too large")
|
||||
|
||||
skill_dir = self.resolve_local_skill_dir(skill_name)
|
||||
if SkillManager().is_plugin_skill(skill_name):
|
||||
raise SkillsServiceError("Plugin-provided skill is read-only.")
|
||||
skill_mgr = SkillManager()
|
||||
if self.is_skill_dir_readonly(skill_mgr, skill_dir):
|
||||
raise SkillsServiceError("This skill is read-only.")
|
||||
target_file = self.resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 273 icons */
|
||||
/* Auto-generated MDI subset – 272 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -404,10 +404,6 @@
|
||||
content: "\F0209";
|
||||
}
|
||||
|
||||
.mdi-eye-outline::before {
|
||||
content: "\F06D0";
|
||||
}
|
||||
|
||||
.mdi-eyedropper::before {
|
||||
content: "\F020A";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -950,6 +950,7 @@ export default {
|
||||
plugin: skill?.source_label || skill?.plugin_name || "",
|
||||
});
|
||||
}
|
||||
if (sourceType === "global") return tm("skills.sourceGlobal");
|
||||
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
|
||||
if (sourceType === "both") return tm("skills.sourceBoth");
|
||||
return tm("skills.sourceLocalOnly");
|
||||
@@ -958,6 +959,7 @@ export default {
|
||||
const sourceTypeColor = (sourceType) => {
|
||||
if (sourceType === "sandbox_only") return "indigo";
|
||||
if (sourceType === "plugin") return "secondary";
|
||||
if (sourceType === "global") return "teal";
|
||||
if (sourceType === "both") return "success";
|
||||
return "primary";
|
||||
};
|
||||
@@ -966,7 +968,9 @@ export default {
|
||||
skill?.source_type === "sandbox_only";
|
||||
const isPluginProvidedSkill = (skill) => skill?.source_type === "plugin";
|
||||
const isReadOnlySourceSkill = (skill) =>
|
||||
isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill);
|
||||
!!skill?.readonly ||
|
||||
isSandboxPresetSkill(skill) ||
|
||||
isPluginProvidedSkill(skill);
|
||||
|
||||
const normalizeNeoItemsPayload = (res) => {
|
||||
const payload = res?.data?.data || [];
|
||||
@@ -1266,6 +1270,10 @@ export default {
|
||||
showMessage(tm("skills.pluginReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
if (isReadOnlySourceSkill(skill)) {
|
||||
showMessage(tm("skills.readonlySource"), "warning");
|
||||
return;
|
||||
}
|
||||
skillToDelete.value = skill;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
@@ -1300,6 +1308,10 @@ export default {
|
||||
showMessage(tm("skills.pluginReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
if (isReadOnlySourceSkill(skill)) {
|
||||
showMessage(tm("skills.readonlySource"), "warning");
|
||||
return;
|
||||
}
|
||||
itemLoading[skill.name] = true;
|
||||
try {
|
||||
const res = await skillApi.download(skill.name);
|
||||
@@ -1399,7 +1411,7 @@ export default {
|
||||
editorDialog.show = true;
|
||||
const entries = await loadSkillDir("");
|
||||
const skillMd = entries.find((entry) => entry.path === "SKILL.md");
|
||||
if (skillMd?.editable) {
|
||||
if (skillMd) {
|
||||
await loadSkillFile(skillMd.path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -402,9 +402,11 @@
|
||||
"sourceSandboxOnly": "Sandbox Preset Skill",
|
||||
"sourceBoth": "Local + Sandbox",
|
||||
"sourcePlugin": "Plugin: {plugin}",
|
||||
"sourceGlobal": "Global Skill",
|
||||
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
|
||||
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills.",
|
||||
"pluginReadonly": "Plugin-provided skills are managed by their plugin. They cannot be deleted or downloaded from Local Skills.",
|
||||
"readonlySource": "This Skill is read-only here. It cannot be deleted or downloaded from Local Skills.",
|
||||
"openEditor": "View/Edit",
|
||||
"editorTitle": "Edit Skill",
|
||||
"editorLoadFailed": "Failed to load Skill file",
|
||||
|
||||
@@ -397,9 +397,11 @@
|
||||
"sourceSandboxOnly": "Предустановленный Sandbox навык",
|
||||
"sourceBoth": "Локальный + Sandbox",
|
||||
"sourcePlugin": "Плагин: {plugin}",
|
||||
"sourceGlobal": "Глобальный навык",
|
||||
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
|
||||
"pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.",
|
||||
"readonlySource": "Этот навык доступен здесь только для чтения и не может быть удален или скачан.",
|
||||
"openEditor": "Просмотр/правка",
|
||||
"editorTitle": "Редактировать навык",
|
||||
"editorLoadFailed": "Не удалось открыть файл навыка",
|
||||
|
||||
@@ -402,9 +402,11 @@
|
||||
"sourceSandboxOnly": "Sandbox 预置 Skill",
|
||||
"sourceBoth": "本地 + Sandbox",
|
||||
"sourcePlugin": "插件:{plugin}",
|
||||
"sourceGlobal": "全局 Skill",
|
||||
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
|
||||
"pluginReadonly": "插件提供的 Skill 由插件管理,无法在本地 Skills 页面删除或下载。",
|
||||
"readonlySource": "该 Skill 在此处为只读,无法在本地 Skills 页面删除或下载。",
|
||||
"openEditor": "查看/编辑",
|
||||
"editorTitle": "编辑 Skill",
|
||||
"editorLoadFailed": "读取 Skill 文件失败",
|
||||
|
||||
@@ -496,6 +496,43 @@ def test_list_workspace_skills_parses_workspace_skill(tmp_path: Path):
|
||||
assert skill.path.endswith("workspace/skills/workspace-skill/SKILL.md")
|
||||
|
||||
|
||||
def test_list_workspace_skills_prefers_agent_workspace_skill(tmp_path: Path):
|
||||
skills_root = tmp_path / "skills"
|
||||
plugins_root = tmp_path / "plugins"
|
||||
workspace_root = tmp_path / "workspace"
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
plugins_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
agent_skill_dir = workspace_root / ".agent" / "skills" / "workspace-skill"
|
||||
agent_skill_dir.mkdir(parents=True)
|
||||
agent_skill_dir.joinpath("SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: workspace-skill\n"
|
||||
"description: Agent workspace scoped skill.\n"
|
||||
"---\n"
|
||||
"# Agent Workspace Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
legacy_skill_dir = workspace_root / "skills" / "workspace-skill"
|
||||
legacy_skill_dir.mkdir(parents=True)
|
||||
legacy_skill_dir.joinpath("SKILL.md").write_text(
|
||||
"---\ndescription: Legacy workspace skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mgr = SkillManager(skills_root=str(skills_root), plugins_root=str(plugins_root))
|
||||
skills = mgr.list_workspace_skills(workspace_root)
|
||||
|
||||
assert len(skills) == 1
|
||||
skill = skills[0]
|
||||
assert skill.name == "workspace-skill"
|
||||
assert skill.description == "Agent workspace scoped skill."
|
||||
assert skill.source_type == "workspace"
|
||||
assert skill.readonly is True
|
||||
assert skill.path.endswith("workspace/.agent/skills/workspace-skill/SKILL.md")
|
||||
|
||||
|
||||
def test_list_workspace_skills_skips_invalid_names_and_legacy_files(tmp_path: Path):
|
||||
skills_root = tmp_path / "skills"
|
||||
plugins_root = tmp_path / "plugins"
|
||||
@@ -569,6 +606,150 @@ def test_list_workspace_skills_rejects_symlinked_root_outside_workspace(
|
||||
assert mgr.list_workspace_skills(workspace_root) == []
|
||||
|
||||
|
||||
def test_list_skills_includes_global_agent_skills(monkeypatch, tmp_path: Path):
|
||||
data_dir = tmp_path / "data"
|
||||
skills_root = tmp_path / "skills"
|
||||
plugins_root = tmp_path / "plugins"
|
||||
global_skills_root = tmp_path / "home" / ".agent" / "skills"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
plugins_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
|
||||
lambda: str(data_dir),
|
||||
)
|
||||
|
||||
global_skill_dir = global_skills_root / "global-skill"
|
||||
global_skill_dir.mkdir(parents=True)
|
||||
global_skill_dir.joinpath("SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: global-skill\n"
|
||||
"description: Global Agent skill.\n"
|
||||
"---\n"
|
||||
"# Global Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mgr = SkillManager(
|
||||
skills_root=str(skills_root),
|
||||
plugins_root=str(plugins_root),
|
||||
global_skills_root=str(global_skills_root),
|
||||
)
|
||||
skills = mgr.list_skills()
|
||||
|
||||
assert len(skills) == 1
|
||||
skill = skills[0]
|
||||
assert skill.name == "global-skill"
|
||||
assert skill.description == "Global Agent skill."
|
||||
assert skill.source_type == "global"
|
||||
assert skill.source_label == "global"
|
||||
assert skill.readonly is True
|
||||
assert skill.path.endswith("home/.agent/skills/global-skill/SKILL.md")
|
||||
|
||||
|
||||
def test_global_agent_skill_is_not_treated_as_sandbox_only(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
):
|
||||
data_dir = tmp_path / "data"
|
||||
temp_dir = tmp_path / "temp"
|
||||
skills_root = tmp_path / "skills"
|
||||
plugins_root = tmp_path / "plugins"
|
||||
global_skills_root = tmp_path / "home" / ".agent" / "skills"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
plugins_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
|
||||
lambda: str(data_dir),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
|
||||
lambda: str(temp_dir),
|
||||
)
|
||||
|
||||
global_skill_dir = global_skills_root / "global-skill"
|
||||
global_skill_dir.mkdir(parents=True)
|
||||
global_skill_dir.joinpath("SKILL.md").write_text(
|
||||
"---\ndescription: Global Agent skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mgr = SkillManager(
|
||||
skills_root=str(skills_root),
|
||||
plugins_root=str(plugins_root),
|
||||
global_skills_root=str(global_skills_root),
|
||||
)
|
||||
mgr.set_sandbox_skills_cache(
|
||||
[
|
||||
{
|
||||
"name": "global-skill",
|
||||
"description": "synced global skill",
|
||||
"path": "skills/global-skill/SKILL.md",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
mgr.set_skill_active("global-skill", False)
|
||||
|
||||
skills = mgr.list_skills(runtime="sandbox")
|
||||
assert skills[0].name == "global-skill"
|
||||
assert skills[0].source_type == "global"
|
||||
assert skills[0].sandbox_exists is True
|
||||
assert skills[0].active is False
|
||||
|
||||
|
||||
def test_skills_service_reads_global_agent_skill_as_readonly(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
):
|
||||
from astrbot.dashboard.services.skills_service import SkillsService
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
skills_root = tmp_path / "skills"
|
||||
plugins_root = tmp_path / "plugins"
|
||||
global_skills_root = tmp_path / "home" / ".agent" / "skills"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
plugins_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
|
||||
lambda: str(data_dir),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
|
||||
lambda: str(skills_root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_plugin_path",
|
||||
lambda: str(plugins_root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager._default_global_skills_root",
|
||||
lambda: str(global_skills_root),
|
||||
)
|
||||
|
||||
global_skill_dir = global_skills_root / "global-skill"
|
||||
global_skill_dir.mkdir(parents=True)
|
||||
global_skill_dir.joinpath("SKILL.md").write_text(
|
||||
"---\ndescription: Global Agent skill.\n---\n# Global\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = SkillsService(core_lifecycle=object())
|
||||
files = service.list_skill_files("global-skill")
|
||||
skill_md = next(item for item in files["entries"] if item["path"] == "SKILL.md")
|
||||
content = service.get_skill_file("global-skill", "SKILL.md")
|
||||
|
||||
assert skill_md["editable"] is False
|
||||
assert content["editable"] is False
|
||||
assert "# Global" in content["content"]
|
||||
|
||||
|
||||
def test_list_skills_includes_plugin_provided_skills(monkeypatch, tmp_path: Path):
|
||||
import astrbot.core.star.star as star_module
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
Reference in New Issue
Block a user