feat: support global Agent skills

This commit is contained in:
Soulter
2026-07-14 09:01:22 +08:00
parent a298c7bf49
commit b0ab050f34
10 changed files with 440 additions and 117 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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";
}

View File

@@ -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);
}
};

View File

@@ -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",

View File

@@ -397,9 +397,11 @@
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sourcePlugin": "Плагин: {plugin}",
"sourceGlobal": "Глобальный навык",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
"pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.",
"readonlySource": "Этот навык доступен здесь только для чтения и не может быть удален или скачан.",
"openEditor": "Просмотр/правка",
"editorTitle": "Редактировать навык",
"editorLoadFailed": "Не удалось открыть файл навыка",

View File

@@ -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 文件失败",

View File

@@ -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