From 7dc446581b279337acd894c7e7f29f3a899a48a3 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 1 May 2026 20:44:13 +0800
Subject: [PATCH 01/16] feat: supports plugin to add skills
---
astrbot/core/computer/computer_client.py | 38 ++++++-
astrbot/core/skills/skill_manager.py | 107 +++++++++++++++++-
astrbot/dashboard/routes/plugin.py | 16 +++
astrbot/dashboard/routes/skills.py | 36 +++++-
.../components/extension/SkillsSection.vue | 31 +++--
.../locales/en-US/features/extension.json | 2 +
.../locales/ru-RU/features/extension.json | 2 +
.../locales/zh-CN/features/extension.json | 2 +
docs/en/dev/star/plugin-new.md | 27 +++++
docs/zh/dev/star/plugin-new.md | 27 +++++
docs/zh/dev/star/plugin.md | 27 +++++
tests/test_computer_skill_sync.py | 51 +++++++++
tests/test_skill_metadata_enrichment.py | 33 ++++++
13 files changed, 379 insertions(+), 20 deletions(-)
diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py
index 648c771235..2de3199311 100644
--- a/astrbot/core/computer/computer_client.py
+++ b/astrbot/core/computer/computer_client.py
@@ -31,6 +31,24 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
return skills
+def _collect_sync_skill_dirs() -> list[tuple[str, Path]]:
+ """Collect local and plugin-provided skills that should be synced."""
+ skill_manager = SkillManager(skills_root=str(Path(get_astrbot_skills_path())))
+ sync_dirs: list[tuple[str, Path]] = []
+ for skill in skill_manager.list_skills(
+ active_only=False,
+ runtime="local",
+ show_sandbox_path=False,
+ ):
+ if skill.source_type == "sandbox_only":
+ continue
+ skill_md = Path(skill.path)
+ if not skill_md.is_file():
+ continue
+ sync_dirs.append((skill.name, skill_md.parent))
+ return sync_dirs
+
+
def _discover_bay_credentials(endpoint: str) -> str:
"""Try to auto-discover Bay API key from credentials.json.
@@ -382,21 +400,24 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
Backward-compatible orchestrator: keep historical behavior while internally
splitting into `apply` and `scan` phases.
"""
- skills_root = Path(get_astrbot_skills_path())
- if not skills_root.is_dir():
- return
- local_skill_dirs = _list_local_skill_dirs(skills_root)
+ sync_skill_dirs = _collect_sync_skill_dirs()
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
zip_base = temp_dir / "skills_bundle"
zip_path = zip_base.with_suffix(".zip")
+ bundle_root = temp_dir / f"skills_bundle_{uuid.uuid4().hex}"
try:
- if local_skill_dirs:
+ if sync_skill_dirs:
if zip_path.exists():
zip_path.unlink()
- shutil.make_archive(str(zip_base), "zip", str(skills_root))
+ if bundle_root.exists():
+ shutil.rmtree(bundle_root)
+ bundle_root.mkdir(parents=True)
+ for skill_name, skill_dir in sync_skill_dirs:
+ shutil.copytree(skill_dir, bundle_root / skill_name)
+ shutil.make_archive(str(zip_base), "zip", str(bundle_root))
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
logger.info("Uploading skills bundle to sandbox...")
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
@@ -420,6 +441,11 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
len(managed),
)
finally:
+ if bundle_root.exists():
+ try:
+ shutil.rmtree(bundle_root)
+ except Exception:
+ logger.warning(f"Failed to remove temp skills bundle: {bundle_root}")
if zip_path.exists():
try:
zip_path.unlink()
diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index a8121c42a4..838301c044 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -16,6 +16,7 @@
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
+ get_astrbot_plugin_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
@@ -64,7 +65,11 @@ def _is_ignored_zip_entry(name: str) -> bool:
return parts[0] == "__MACOSX"
-def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None:
+def _normalize_skill_markdown_path(
+ skill_dir: Path,
+ *,
+ rename_legacy: bool = True,
+) -> Path | None:
"""Return the canonical `SKILL.md` path for a skill directory.
If only legacy `skill.md` exists, it is renamed to `SKILL.md` in-place.
@@ -79,6 +84,8 @@ def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None:
if "skill.md" not in entries:
return None
try:
+ if not rename_legacy:
+ return legacy
tmp = skill_dir / f".{uuid.uuid4().hex}.tmp_skill_md"
legacy.rename(tmp)
tmp.rename(canonical)
@@ -97,6 +104,8 @@ class SkillInfo:
source_label: str = "local"
local_exists: bool = True
sandbox_exists: bool = False
+ plugin_name: str = ""
+ readonly: bool = False
def _parse_frontmatter_description(text: str) -> str:
@@ -274,13 +283,60 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
class SkillManager:
- def __init__(self, skills_root: str | None = None) -> None:
+ def __init__(
+ self,
+ skills_root: str | None = None,
+ plugins_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()
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)
os.makedirs(self.skills_root, exist_ok=True)
+ def _iter_plugin_skill_dirs(self) -> list[tuple[str, str, Path]]:
+ """Return plugin-provided skill directories as (skill, plugin, dir)."""
+ plugins_root = Path(self.plugins_root)
+ if not plugins_root.is_dir():
+ return []
+
+ result: list[tuple[str, str, Path]] = []
+ for plugin_dir in sorted(plugins_root.iterdir(), key=lambda item: item.name):
+ if not plugin_dir.is_dir():
+ continue
+ plugin_name = plugin_dir.name
+ skills_dir = plugin_dir / "skills"
+ if not skills_dir.is_dir():
+ continue
+
+ direct_skill_md = _normalize_skill_markdown_path(
+ skills_dir,
+ rename_legacy=False,
+ )
+ if direct_skill_md is not None and _SKILL_NAME_RE.match(plugin_name):
+ result.append((plugin_name, plugin_name, skills_dir))
+
+ for skill_dir in sorted(skills_dir.iterdir(), key=lambda item: item.name):
+ if not skill_dir.is_dir():
+ continue
+ skill_name = skill_dir.name
+ if not _SKILL_NAME_RE.match(skill_name):
+ continue
+ if (
+ _normalize_skill_markdown_path(skill_dir, rename_legacy=False)
+ is None
+ ):
+ continue
+ result.append((skill_name, plugin_name, skill_dir))
+ return result
+
+ def _get_plugin_skill_dir(self, name: str) -> Path | None:
+ for skill_name, _plugin_name, skill_dir in self._iter_plugin_skill_dirs():
+ if skill_name == name:
+ return skill_dir
+ return None
+
def _load_config(self) -> dict:
if not os.path.exists(self.config_path):
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
@@ -430,6 +486,46 @@ def list_skills(
sandbox_exists=sandbox_exists,
)
+ for skill_name, plugin_name, skill_dir in self._iter_plugin_skill_dirs():
+ if skill_name in skills_by_name:
+ continue
+ skill_md = _normalize_skill_markdown_path(skill_dir, rename_legacy=False)
+ 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
+ )
+ 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)
+ skills_by_name[skill_name] = SkillInfo(
+ name=skill_name,
+ description=description,
+ path=path_str.replace("\\", "/"),
+ active=active,
+ source_type="plugin",
+ source_label=plugin_name,
+ local_exists=True,
+ sandbox_exists=sandbox_exists,
+ plugin_name=plugin_name,
+ readonly=True,
+ )
+
if runtime == "sandbox":
cache = self._load_sandbox_skills_cache()
for item in cache.get("skills", []):
@@ -488,6 +584,9 @@ def is_sandbox_only_skill(self, name: str) -> bool:
return True
return False
+ def is_plugin_skill(self, name: str) -> bool:
+ return self._get_plugin_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(
@@ -521,6 +620,10 @@ 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():
diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index a4f73b7a83..ea16d8602d 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -14,6 +14,7 @@
from astrbot.api import sp
from astrbot.core import DEMO_MODE, file_token_service, logger
+from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
@@ -89,6 +90,12 @@ def __init__(
self._logo_cache = {}
+ async def _sync_skills_after_plugin_change(self) -> None:
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync plugin-provided skills to active sandboxes.")
+
async def check_plugin_compatibility(self):
try:
data = await request.get_json()
@@ -129,6 +136,7 @@ async def reload_failed_plugins(self):
success, err = await self.plugin_manager.reload_failed_plugin(dir_name)
if success:
+ await self._sync_skills_after_plugin_change()
return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__
else:
return Response().error(f"重载失败: {err}").__dict__
@@ -151,6 +159,7 @@ async def reload_plugins(self):
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
return Response().error(message or "插件重载失败").__dict__
+ await self._sync_skills_after_plugin_change()
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
@@ -520,6 +529,7 @@ async def install_plugin(self):
download_url=download_url,
)
# self.core_lifecycle.restart()
+ await self._sync_skills_after_plugin_change()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
@@ -561,6 +571,7 @@ async def install_plugin_upload(self):
ignore_version_check=ignore_version_check,
)
# self.core_lifecycle.restart()
+ await self._sync_skills_after_plugin_change()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
@@ -595,6 +606,7 @@ async def uninstall_plugin(self):
delete_config=delete_config,
delete_data=delete_data,
)
+ await self._sync_skills_after_plugin_change()
logger.info(f"卸载插件 {plugin_name} 成功")
return Response().ok(None, "卸载成功").__dict__
except Exception as e:
@@ -623,6 +635,7 @@ async def uninstall_failed_plugin(self):
delete_config=delete_config,
delete_data=delete_data,
)
+ await self._sync_skills_after_plugin_change()
logger.info(f"卸载失败插件 {dir_name} 成功")
return Response().ok(None, "卸载成功").__dict__
except Exception as e:
@@ -645,6 +658,7 @@ async def update_plugin(self):
await self.plugin_manager.update_plugin(plugin_name, proxy)
# self.core_lifecycle.restart()
await self.plugin_manager.reload(plugin_name)
+ await self._sync_skills_after_plugin_change()
logger.info(f"更新插件 {plugin_name} 成功。")
return Response().ok(None, "更新成功。").__dict__
except Exception as e:
@@ -696,6 +710,8 @@ async def _update_one(name: str):
results.append(result)
failed = [r for r in results if r["status"] == "error"]
+ if len(failed) < len(results):
+ await self._sync_skills_after_plugin_change()
message = (
"批量更新完成,全部成功。"
if not failed
diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py
index d7676987d2..c86598212e 100644
--- a/astrbot/dashboard/routes/skills.py
+++ b/astrbot/dashboard/routes/skills.py
@@ -112,6 +112,10 @@ def _resolve_local_skill_dir(self, name: str) -> Path:
"Sandbox preset skill cannot be opened from local skill files."
)
+ 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):
@@ -153,7 +157,13 @@ def _is_editable_skill_file(path: Path) -> bool:
or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES
)
- def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict:
+ def _serialize_skill_file_entry(
+ self,
+ skill_dir: Path,
+ path: Path,
+ *,
+ readonly: bool = False,
+ ) -> dict:
stat = path.stat()
is_dir = path.is_dir()
return {
@@ -162,7 +172,8 @@ def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict:
"type": "directory" if is_dir else "file",
"size": 0 if is_dir else stat.st_size,
"editable": (
- (not is_dir)
+ not readonly
+ and (not is_dir)
and self._is_editable_skill_file(path)
and stat.st_size <= _SKILL_FILE_MAX_BYTES
),
@@ -478,6 +489,14 @@ async def download_skill(self):
)
.__dict__
)
+ if skill_mgr.is_plugin_skill(name):
+ return (
+ Response()
+ .error(
+ "Plugin-provided skill cannot be downloaded from local skill files."
+ )
+ .__dict__
+ )
skill_dir = Path(skill_mgr.skills_root) / name
skill_md = skill_dir / "SKILL.md"
@@ -512,6 +531,7 @@ async def list_skill_files(self):
try:
name = str(request.args.get("name") or "").strip()
relative_path = request.args.get("path", "")
+ readonly = SkillManager().is_plugin_skill(name)
skill_dir = self._resolve_local_skill_dir(name)
target_dir = self._resolve_skill_relative_path(
skill_dir,
@@ -532,7 +552,13 @@ async def list_skill_files(self):
continue
if not resolved.is_dir() and not resolved.is_file():
continue
- entries.append(self._serialize_skill_file_entry(skill_dir, resolved))
+ entries.append(
+ self._serialize_skill_file_entry(
+ skill_dir,
+ resolved,
+ readonly=readonly,
+ )
+ )
return (
Response()
@@ -579,7 +605,7 @@ async def get_skill_file(self):
"path": self._skill_relative_path(skill_dir, target_file),
"content": content,
"size": size,
- "editable": True,
+ "editable": not SkillManager().is_plugin_skill(name),
}
)
.__dict__
@@ -609,6 +635,8 @@ async def update_skill_file(self):
return Response().error("File content is too large").__dict__
skill_dir = self._resolve_local_skill_dir(name)
+ if SkillManager().is_plugin_skill(name):
+ return Response().error("Plugin-provided skill is read-only.").__dict__
target_file = self._resolve_skill_relative_path(
skill_dir,
relative_path,
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index b263ef7519..2ad1bddffe 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -66,7 +66,7 @@
variant="tonal"
:color="sourceTypeColor(skill.source_type)"
>
- {{ sourceTypeLabel(skill.source_type) }}
+ {{ sourceTypeLabel(skill.source_type, skill) }}
@@ -88,11 +88,7 @@
variant="text"
size="small"
class="list-action-icon-btn"
- :disabled="
- itemLoading[skill.name] ||
- false ||
- isSandboxPresetSkill(skill)
- "
+ :disabled="itemLoading[skill.name] || isReadOnlySourceSkill(skill)"
@click.stop="downloadSkill(skill)"
/>
@@ -106,7 +102,7 @@
variant="text"
size="small"
class="list-action-icon-btn"
- :disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
+ :disabled="itemLoading[skill.name] || isReadOnlySourceSkill(skill)"
@click.stop="confirmDelete(skill)"
/>
@@ -948,7 +944,12 @@ export default {
return payload.skills || [];
};
- const sourceTypeLabel = (sourceType) => {
+ const sourceTypeLabel = (sourceType, skill = null) => {
+ if (sourceType === "plugin") {
+ return tm("skills.sourcePlugin", {
+ plugin: skill?.source_label || skill?.plugin_name || "",
+ });
+ }
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
if (sourceType === "both") return tm("skills.sourceBoth");
return tm("skills.sourceLocalOnly");
@@ -956,12 +957,16 @@ export default {
const sourceTypeColor = (sourceType) => {
if (sourceType === "sandbox_only") return "indigo";
+ if (sourceType === "plugin") return "secondary";
if (sourceType === "both") return "success";
return "primary";
};
const isSandboxPresetSkill = (skill) =>
skill?.source_type === "sandbox_only";
+ const isPluginProvidedSkill = (skill) => skill?.source_type === "plugin";
+ const isReadOnlySourceSkill = (skill) =>
+ isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill);
const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
@@ -1265,6 +1270,10 @@ export default {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
+ if (isPluginProvidedSkill(skill)) {
+ showMessage(tm("skills.pluginReadonly"), "warning");
+ return;
+ }
skillToDelete.value = skill;
deleteDialog.value = true;
};
@@ -1297,6 +1306,10 @@ export default {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
+ if (isPluginProvidedSkill(skill)) {
+ showMessage(tm("skills.pluginReadonly"), "warning");
+ return;
+ }
itemLoading[skill.name] = true;
try {
const res = await axios.get("/api/skills/download", {
@@ -1827,6 +1840,8 @@ export default {
sourceTypeLabel,
sourceTypeColor,
isSandboxPresetSkill,
+ isPluginProvidedSkill,
+ isReadOnlySourceSkill,
};
},
};
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 7ae60532dc..ed4d56d1b3 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -356,8 +356,10 @@
"sourceLocalOnly": "Local Skill",
"sourceSandboxOnly": "Sandbox Preset Skill",
"sourceBoth": "Local + Sandbox",
+ "sourcePlugin": "Plugin: {plugin}",
"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.",
"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 194736e023..5da5c71203 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -355,8 +355,10 @@
"sourceLocalOnly": "Локальный навык",
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
+ "sourcePlugin": "Плагин: {plugin}",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
+ "pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.",
"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 f615f8e3fd..0eb7d92999 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -356,8 +356,10 @@
"sourceLocalOnly": "本地 Skill",
"sourceSandboxOnly": "Sandbox 预置 Skill",
"sourceBoth": "本地 + Sandbox",
+ "sourcePlugin": "插件:{plugin}",
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
+ "pluginReadonly": "插件提供的 Skill 由插件管理,无法在本地 Skills 页面删除或下载。",
"openEditor": "查看/编辑",
"editorTitle": "编辑 Skill",
"editorLoadFailed": "读取 Skill 文件失败",
diff --git a/docs/en/dev/star/plugin-new.md b/docs/en/dev/star/plugin-new.md
index 41dac43d01..b3e6845dbb 100644
--- a/docs/en/dev/star/plugin-new.md
+++ b/docs/en/dev/star/plugin-new.md
@@ -61,6 +61,33 @@ You can add a `short_desc` field to `metadata.yaml` as the short description sho
short_desc: A one-line summary of your plugin.
```
+### Bundle Skills with a Plugin (Optional)
+
+Plugins can provide a `skills/` directory. After AstrBot loads the plugin, valid Skills inside that directory are automatically included in the Skill Manager, with their source shown as the plugin.
+
+For multiple Skills, use this structure:
+
+```text
+your_plugin/
+ metadata.yaml
+ main.py
+ skills/
+ web-search-helper/
+ SKILL.md
+ report-writer/
+ SKILL.md
+```
+
+If `skills/` itself is one Skill, you can also place `SKILL.md` directly under it:
+
+```text
+your_plugin/
+ skills/
+ SKILL.md
+```
+
+In that case, the Skill name uses the plugin directory name. Plugin-provided Skills are managed by the plugin and appear as read-only sources in the WebUI Skills page. They can be enabled or disabled, but cannot be deleted or edited from Local Skills. When the plugin is uninstalled or updated, its bundled Skills change with the plugin files.
+
### Declare Supported Platforms (Optional)
You can add a `support_platforms` field (`list[str]`) to `metadata.yaml` to declare which platform adapters your plugin supports. The WebUI plugin page will display this field.
diff --git a/docs/zh/dev/star/plugin-new.md b/docs/zh/dev/star/plugin-new.md
index 86262fe963..04d61deae6 100644
--- a/docs/zh/dev/star/plugin-new.md
+++ b/docs/zh/dev/star/plugin-new.md
@@ -63,6 +63,33 @@ git clone 插件仓库地址
short_desc: 一句话介绍你的插件。
```
+### 随插件提供 Skills(可选)
+
+插件可以在自己的目录下提供 `skills/` 文件夹。AstrBot 加载插件后会自动把其中合法的 Skill 纳入 Skill Manager,来源会显示为对应插件。
+
+推荐一个插件包含多个 Skill 时使用以下结构:
+
+```text
+your_plugin/
+ metadata.yaml
+ main.py
+ skills/
+ web-search-helper/
+ SKILL.md
+ report-writer/
+ SKILL.md
+```
+
+如果 `skills/` 本身就是一个 Skill,也可以直接放置:
+
+```text
+your_plugin/
+ skills/
+ SKILL.md
+```
+
+这种情况下 Skill 名称会使用插件目录名。插件提供的 Skill 由插件管理,在 WebUI 的 Skills 页面中作为只读来源展示;可以启用或禁用,但不能从本地 Skills 页面删除或编辑。插件卸载或更新后,对应 Skill 会随插件文件变化。
+
### 声明支持平台(Optional)
你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`),声明插件支持的平台适配器。WebUI 插件页会展示该字段。
diff --git a/docs/zh/dev/star/plugin.md b/docs/zh/dev/star/plugin.md
index fc60a1b22b..84ac03ff46 100644
--- a/docs/zh/dev/star/plugin.md
+++ b/docs/zh/dev/star/plugin.md
@@ -234,6 +234,33 @@ async def on_message(self, event: AstrMessageEvent):
short_desc: 一句话介绍你的插件。
```
+### 随插件提供 Skills
+
+插件可以在自己的目录下提供 `skills/` 文件夹。AstrBot 加载插件后会自动把其中合法的 Skill 纳入 Skill Manager,来源会显示为对应插件。
+
+推荐一个插件包含多个 Skill 时使用以下结构:
+
+```text
+your_plugin/
+ metadata.yaml
+ main.py
+ skills/
+ web-search-helper/
+ SKILL.md
+ report-writer/
+ SKILL.md
+```
+
+如果 `skills/` 本身就是一个 Skill,也可以直接放置:
+
+```text
+your_plugin/
+ skills/
+ SKILL.md
+```
+
+这种情况下 Skill 名称会使用插件目录名。插件提供的 Skill 由插件管理,在 WebUI 的 Skills 页面中作为只读来源展示;可以启用或禁用,但不能从本地 Skills 页面删除或编辑。插件卸载或更新后,对应 Skill 会随插件文件变化。
+
### 声明支持平台(Optional)
你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`),声明插件支持的平台适配器。WebUI 插件页会展示该字段。
diff --git a/tests/test_computer_skill_sync.py b/tests/test_computer_skill_sync.py
index 37715bb74b..81cf62cff4 100644
--- a/tests/test_computer_skill_sync.py
+++ b/tests/test_computer_skill_sync.py
@@ -137,6 +137,57 @@ def _fake_set_cache(self, skills):
]
+def test_sync_skills_includes_plugin_provided_skills(
+ monkeypatch,
+ tmp_path: Path,
+):
+ skills_root = tmp_path / "skills"
+ plugins_root = tmp_path / "plugins"
+ temp_root = tmp_path / "temp"
+ skills_root.mkdir(parents=True, exist_ok=True)
+ temp_root.mkdir(parents=True, exist_ok=True)
+ plugin_skill_dir = plugins_root / "astrbot_plugin_demo" / "skills" / "demo-skill"
+ plugin_skill_dir.mkdir(parents=True)
+ plugin_skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
+
+ captured = {"skills": None}
+
+ def _fake_set_cache(self, skills):
+ captured["skills"] = skills
+
+ monkeypatch.setattr(
+ "astrbot.core.computer.computer_client.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.computer.computer_client.get_astrbot_temp_path",
+ lambda: str(temp_root),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
+ _fake_set_cache,
+ )
+
+ booter = _FakeBooter(
+ '{"skills":[{"name":"demo-skill","description":"","path":"skills/demo-skill/SKILL.md"}]}'
+ )
+ asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter)))
+
+ assert len(booter.uploads) == 1
+ assert booter.uploads[0][1] == "skills/skills.zip"
+ assert captured["skills"] == [
+ {
+ "name": "demo-skill",
+ "description": "",
+ "path": "skills/demo-skill/SKILL.md",
+ }
+ ]
+
+
def test_build_scan_command_frontmatter_newline_is_escaped_literal():
command = computer_client._build_scan_command()
script = _extract_embedded_python(command)
diff --git a/tests/test_skill_metadata_enrichment.py b/tests/test_skill_metadata_enrichment.py
index 78d84104f9..3719331b09 100644
--- a/tests/test_skill_metadata_enrichment.py
+++ b/tests/test_skill_metadata_enrichment.py
@@ -441,6 +441,39 @@ def test_list_skills_parses_description_from_local(monkeypatch, tmp_path: Path):
assert not hasattr(s, "output")
+def test_list_skills_includes_plugin_provided_skills(monkeypatch, tmp_path: Path):
+ data_dir = tmp_path / "data"
+ skills_root = tmp_path / "skills"
+ plugins_root = tmp_path / "plugins"
+ data_dir.mkdir(parents=True, exist_ok=True)
+ skills_root.mkdir(parents=True, exist_ok=True)
+
+ monkeypatch.setattr(
+ "astrbot.core.skills.skill_manager.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+
+ plugin_skill_dir = plugins_root / "astrbot_plugin_demo" / "skills" / "demo-skill"
+ plugin_skill_dir.mkdir(parents=True)
+ plugin_skill_dir.joinpath("SKILL.md").write_text(
+ "---\nname: demo-skill\ndescription: Plugin bundled skill.\n---\n# Demo\n",
+ encoding="utf-8",
+ )
+
+ mgr = SkillManager(skills_root=str(skills_root), plugins_root=str(plugins_root))
+ skills = mgr.list_skills()
+
+ assert len(skills) == 1
+ skill = skills[0]
+ assert skill.name == "demo-skill"
+ assert skill.description == "Plugin bundled skill."
+ assert skill.source_type == "plugin"
+ assert skill.source_label == "astrbot_plugin_demo"
+ assert skill.plugin_name == "astrbot_plugin_demo"
+ assert skill.readonly is True
+ assert skill.path.endswith("plugins/astrbot_plugin_demo/skills/demo-skill/SKILL.md")
+
+
def test_list_skills_description_from_sandbox_cache(monkeypatch, tmp_path: Path):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
From 50c31c67b1fb3c832ed2f3e2a5e4b3bd393d9be0 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 3 May 2026 14:47:25 +0800
Subject: [PATCH 02/16] fix tests
---
astrbot/core/computer/computer_client.py | 25 +++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py
index 2de3199311..a50549fba7 100644
--- a/astrbot/core/computer/computer_client.py
+++ b/astrbot/core/computer/computer_client.py
@@ -33,7 +33,16 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
def _collect_sync_skill_dirs() -> list[tuple[str, Path]]:
"""Collect local and plugin-provided skills that should be synced."""
- skill_manager = SkillManager(skills_root=str(Path(get_astrbot_skills_path())))
+ skills_root = Path(get_astrbot_skills_path())
+ if not skills_root.is_dir():
+ return []
+
+ try:
+ skill_manager = SkillManager(skills_root=str(skills_root))
+ except OSError as exc:
+ logger.warning("[Computer] Failed to initialize skill manager: %s", exc)
+ return []
+
sync_dirs: list[tuple[str, Path]] = []
for skill in skill_manager.list_skills(
active_only=False,
@@ -49,6 +58,12 @@ def _collect_sync_skill_dirs() -> list[tuple[str, Path]]:
return sync_dirs
+def _normalize_shell_exec_result(result: object) -> dict:
+ if isinstance(result, dict):
+ return result
+ return {"exit_code": 0, "stdout": "", "stderr": ""}
+
+
def _discover_bay_credentials(endpoint: str) -> str:
"""Try to auto-discover Bay API key from credentials.json.
@@ -369,7 +384,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
executed in a separate phase to keep failure domains clear.
"""
logger.info("[Computer] Skill sync phase=apply start")
- apply_result = await booter.shell.exec(_build_apply_sync_command())
+ apply_result = _normalize_shell_exec_result(
+ await booter.shell.exec(_build_apply_sync_command())
+ )
if not _shell_exec_succeeded(apply_result):
detail = _format_exec_error_detail(apply_result)
logger.error("[Computer] Skill sync phase=apply failed: %s", detail)
@@ -380,7 +397,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None:
"""Scan sandbox skills and return normalized payload for cache update."""
logger.info("[Computer] Skill sync phase=scan start")
- scan_result = await booter.shell.exec(_build_scan_command())
+ scan_result = _normalize_shell_exec_result(
+ await booter.shell.exec(_build_scan_command())
+ )
if not _shell_exec_succeeded(scan_result):
detail = _format_exec_error_detail(scan_result)
logger.error("[Computer] Skill sync phase=scan failed: %s", detail)
From c847aea3bdffd624fb0651d100fb73bcbc9b8edc Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 3 May 2026 15:01:41 +0800
Subject: [PATCH 03/16] fix: fs tools
---
astrbot/core/tools/computer_tools/fs.py | 85 ++++++++++++++++++++-----
docs/en/use/computer.md | 3 +-
docs/zh/use/computer.md | 3 +-
tests/test_computer_fs_tools.py | 64 ++++++++++++++++++-
4 files changed, 137 insertions(+), 18 deletions(-)
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index f15f28dec9..5660022fd0 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -12,9 +12,11 @@
access depends on the local runtime implementation and host OS permissions.
Upload and download tools are defined here, but `LocalBooter` does not
implement them and the main agent does not expose them in local mode.
-- Member + local: read/write/edit/grep are restricted to `data/skills`,
- `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`. Upload/download are
- denied by `check_admin_permission` if invoked.
+- Member + local: read/grep are restricted to `data/skills`,
+ plugin-provided `data/plugins/*/skills`,
+ `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`; write/edit are
+ restricted to the same local roots except plugin-provided Skills, which are
+ read-only. Upload/download are denied by `check_admin_permission` if invoked.
- Admin + sandbox: read/write/edit/grep are not path-restricted by this
module;
sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and
@@ -45,6 +47,7 @@
from astrbot.core.computer.file_read_utils import read_file_tool_result
from astrbot.core.message.components import File, Image
from astrbot.core.utils.astrbot_path import (
+ get_astrbot_plugin_path,
get_astrbot_skills_path,
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
@@ -67,15 +70,22 @@
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
-def _restricted_env_path_labels(umo: str) -> list[str]:
+def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
normalized_umo = normalize_umo_for_workspace(umo)
- return [
+ labels = [
"data/skills",
- f"data/workspaces/{normalized_umo}",
- get_astrbot_system_tmp_path(),
- get_astrbot_temp_path(),
]
+ if include_plugin_skills:
+ labels.append("data/plugins/*/skills")
+ labels.extend(
+ [
+ f"data/workspaces/{normalized_umo}",
+ get_astrbot_system_tmp_path(),
+ get_astrbot_temp_path(),
+ ]
+ )
+ return labels
def get_astrbot_workspaces_path() -> str:
@@ -89,8 +99,30 @@ def _workspace_root(umo: str) -> Path:
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
+def _plugin_skill_roots() -> tuple[Path, ...]:
+ plugins_root = Path(get_astrbot_plugin_path())
+ if not plugins_root.exists():
+ return ()
+ return tuple(
+ (plugin_dir / "skills").resolve(strict=False)
+ for plugin_dir in plugins_root.iterdir()
+ if plugin_dir.is_dir() and (plugin_dir / "skills").is_dir()
+ )
+
+
def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
"""Non-admin users can only read files within these directories (and their subdirectories)"""
+ return (
+ Path(get_astrbot_skills_path()).resolve(strict=False),
+ *_plugin_skill_roots(),
+ _workspace_root(umo),
+ Path(get_astrbot_system_tmp_path()).resolve(strict=False),
+ Path(get_astrbot_temp_path()).resolve(strict=False),
+ )
+
+
+def _write_allowed_roots(umo: str) -> tuple[Path, ...]:
+ """Non-admin users cannot modify plugin-provided Skills."""
return (
Path(get_astrbot_skills_path()).resolve(strict=False),
_workspace_root(umo),
@@ -131,11 +163,16 @@ def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
return (Path.cwd() / candidate).resolve(strict=False)
-def _is_path_within_allowed_roots(path: str, umo: str) -> bool:
+def _is_path_within_allowed_roots(
+ path: str,
+ *,
+ umo: str,
+ allowed_roots: tuple[Path, ...],
+) -> bool:
resolved = _resolve_user_path(path, local_env=True, umo=umo)
return any(
resolved == allowed_root or resolved.is_relative_to(allowed_root)
- for allowed_root in _read_allowed_roots(umo)
+ for allowed_root in allowed_roots
)
@@ -145,14 +182,24 @@ def _normalize_rw_path(
restricted: bool,
local_env: bool,
umo: str,
+ write: bool = False,
) -> str:
normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
- if restricted and not _is_path_within_allowed_roots(normalized_path, umo):
- allowed = ", ".join(_restricted_env_path_labels(umo))
+ if restricted:
+ allowed_roots = _write_allowed_roots(umo) if write else _read_allowed_roots(umo)
+ if restricted and not _is_path_within_allowed_roots(
+ normalized_path,
+ umo=umo,
+ allowed_roots=allowed_roots,
+ ):
+ allowed = ", ".join(
+ _restricted_env_path_labels(umo, include_plugin_skills=not write)
+ )
+ access = "Write" if write else "Read"
raise PermissionError(
- "Read access is restricted for this user. "
+ f"{access} access is restricted for this user. "
f"Allowed directories: {allowed}. Blocked path: {normalized_path}."
)
return normalized_path
@@ -290,6 +337,7 @@ async def call(
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
+ write=True,
)
if local_env
else path.strip()
@@ -368,6 +416,7 @@ async def call(
restricted=restricted,
local_env=local_env,
umo=umo,
+ write=True,
)
if local_env
else path.strip()
@@ -532,10 +581,16 @@ def _normalize_search_paths(
disallowed = [
path
for path in normalized
- if not _is_path_within_allowed_roots(path, umo)
+ if not _is_path_within_allowed_roots(
+ path,
+ umo=umo,
+ allowed_roots=_read_allowed_roots(umo),
+ )
]
if disallowed:
- allowed = ", ".join(_restricted_env_path_labels(umo))
+ allowed = ", ".join(
+ _restricted_env_path_labels(umo, include_plugin_skills=True)
+ )
blocked = ", ".join(disallowed)
raise PermissionError(
"Read access is restricted for this user. "
diff --git a/docs/en/use/computer.md b/docs/en/use/computer.md
index 09fcd87ad0..e217e6f073 100644
--- a/docs/en/use/computer.md
+++ b/docs/en/use/computer.md
@@ -76,11 +76,12 @@ When enabled:
- Admin users can use Shell, Python, file read, file write, file edit, and Grep search in `local` mode.
- Non-admin users cannot use Shell or Python.
-- Non-admin users can only use file read, write, edit, and search inside restricted directories.
+- Non-admin users can only use file read, write, edit, and search inside restricted directories. Plugin-provided Skills are read/search-only and cannot be written or edited.
Allowed directories for non-admin users in `local` mode include:
- `data/skills`
+- `data/plugins/*/skills` (read-only, for plugin-provided Skills)
- Current session's `data/workspaces/{normalized_umo}`
- AstrBot temporary directories
- `.astrbot` under the system temporary directory
diff --git a/docs/zh/use/computer.md b/docs/zh/use/computer.md
index 2b98420813..7da8dd5d17 100644
--- a/docs/zh/use/computer.md
+++ b/docs/zh/use/computer.md
@@ -74,11 +74,12 @@ data/workspaces/{normalized_umo}/notes/todo.txt
- 管理员可以使用 `local` 模式下的 Shell、Python、文件读取、文件写入、文件编辑和 Grep 搜索。
- 非管理员不能使用 Shell 和 Python。
-- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。
+- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。插件内置 Skills 只允许读取和搜索,不允许写入或编辑。
非管理员在 `local` 模式下允许访问的目录包括:
- `data/skills`
+- `data/plugins/*/skills`(只读,用于插件内置 Skills)
- 当前会话的 `data/workspaces/{normalized_umo}`
- AstrBot 的临时目录
- 系统临时目录中的 `.astrbot`
diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py
index a9f6fa16d1..67bfd44f05 100644
--- a/tests/test_computer_fs_tools.py
+++ b/tests/test_computer_fs_tools.py
@@ -49,9 +49,11 @@ def _setup_local_fs_tools(
) -> Any:
workspaces_root = tmp_path / "workspaces"
skills_root = tmp_path / "skills"
+ plugins_root = tmp_path / "plugins"
temp_root = tmp_path / "temp"
workspaces_root.mkdir()
skills_root.mkdir()
+ plugins_root.mkdir()
temp_root.mkdir()
monkeypatch.setattr(
@@ -64,6 +66,11 @@ def _setup_local_fs_tools(
"get_astrbot_skills_path",
lambda: str(skills_root),
)
+ monkeypatch.setattr(
+ fs_tools,
+ "get_astrbot_plugin_path",
+ lambda: str(plugins_root),
+ )
monkeypatch.setattr(
fs_tools,
"get_astrbot_temp_path",
@@ -123,7 +130,9 @@ def _make_epub_bytes(*, chapter_count: int = 1) -> bytes:
'media-type="application/xhtml+xml"/>'
)
spine_items.append(f'
-
- ChatUI
+
+ ChatUI
- {{ t('core.header.updateDialog.dashboardUpdate.hasNewVersion') }} + {{ + t("core.header.updateDialog.dashboardUpdate.hasNewVersion") + }}
- {{ t('core.header.updateDialog.dashboardUpdate.isLatest') }} + {{ t("core.header.updateDialog.dashboardUpdate.isLatest") }}
{{ column.title }}
- - - {{ item.event_type }} - - - {{ item.desc }} - - -- {{ tm("dialogs.updateAllConfirm.message", { count: updatableExtensions.length }) }} + {{ + tm("dialogs.updateAllConfirm.message", { + count: updatableExtensions.length, + }) + }}