diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index d4149c079d..228c6c57fb 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -27,6 +27,7 @@ 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 @@ def __init__( 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 @@ def _get_plugin_skill_dir(self, name: str) -> Path | None: 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 @@ def list_workspace_skills( return [] raw_workspace_root = Path(workspace_root) - skills_root = raw_workspace_root / WORKSPACE_SKILLS_ROOT - if not skills_root.is_dir(): - return [] - - 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 [] + workspace_skill_roots = ( + raw_workspace_root / AGENT_SKILLS_ROOT, + raw_workspace_root / WORKSPACE_SKILLS_ROOT, + ) - 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()} - except OSError: - continue - if "SKILL.md" not in entry_names: - continue - skill_md = skill_dir / "SKILL.md" - if not skill_md.is_file(): + skills_by_name: dict[str, SkillInfo] = {} + for skills_root in workspace_skill_roots: + if not skills_root.is_dir(): continue try: - resolved_skill_md = skill_md.resolve(strict=True) + 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 not resolved_skill_md.is_relative_to(resolved_skills_root): - 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 = "" - - skills.append( - SkillInfo( + 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 + + 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_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 @@ def list_workspace_skills( 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 @@ def list_skills( 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(): - 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: + 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 - 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 @@ def list_skills( 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 @@ def list_skills( 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 @@ def is_sandbox_only_skill(self, name: str) -> bool: 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 @@ def is_sandbox_only_skill(self, name: str) -> bool: 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//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 @@ def delete_skill(self, name: str) -> None: 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. diff --git a/astrbot/dashboard/services/skills_service.py b/astrbot/dashboard/services/skills_service.py index 4bcc6f8810..6e69e26d0c 100644 --- a/astrbot/dashboard/services/skills_service.py +++ b/astrbot/dashboard/services/skills_service.py @@ -133,17 +133,42 @@ def resolve_local_skill_dir(self, name: str) -> Path: "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 @@ def prepare_skill_archive(self, name: str) -> SkillArchive: 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 @@ def prepare_skill_archive_from_dashboard_query( 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 @@ def list_skill_files_from_dashboard_query( 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 @@ def get_skill_file(self, name: str, relative_path: str | None = "SKILL.md") -> d "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 @@ async def update_skill_file(self, data: object) -> dict: 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, diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 04f8fb999f..e9b7e70010 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -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"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 1c5a588f35..de23677c87 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 0c50f94efd..69dda8f8cf 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index 926e11736b..d4bdc9d689 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -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); } }; diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 75313fdba9..6e7fa8741c 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -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", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index d876f08efd..21129a984d 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -397,9 +397,11 @@ "sourceSandboxOnly": "Предустановленный Sandbox навык", "sourceBoth": "Локальный + Sandbox", "sourcePlugin": "Плагин: {plugin}", + "sourceGlobal": "Глобальный навык", "sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.", "sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.", "pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.", + "readonlySource": "Этот навык доступен здесь только для чтения и не может быть удален или скачан.", "openEditor": "Просмотр/правка", "editorTitle": "Редактировать навык", "editorLoadFailed": "Не удалось открыть файл навыка", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index d8ba9869e6..1b91033a6b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -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 文件失败", diff --git a/tests/test_skill_metadata_enrichment.py b/tests/test_skill_metadata_enrichment.py index c74933323e..85c4f48a26 100644 --- a/tests/test_skill_metadata_enrichment.py +++ b/tests/test_skill_metadata_enrichment.py @@ -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