Skip to content

Commit 81f4bd4

Browse files
authored
fix: allow multiple skills in a single zip archive (#7070)
* fix: allow multiple skills in a single zip archive * refactor: address bot review comments * fix: apply ruff format and fix return value
1 parent 4e9916c commit 81f4bd4

File tree

1 file changed

+108
-59
lines changed

1 file changed

+108
-59
lines changed

astrbot/core/skills/skill_manager.py

Lines changed: 108 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,8 @@ def install_skill_from_zip(
548548
if not zipfile.is_zipfile(zip_path):
549549
raise ValueError("Uploaded file is not a valid zip archive.")
550550

551+
installed_skills = []
552+
551553
with zipfile.ZipFile(zip_path) as zf:
552554
names = [
553555
name
@@ -573,34 +575,6 @@ def install_skill_from_zip(
573575
):
574576
raise ValueError("Invalid skill name.")
575577

576-
if root_mode:
577-
archive_hint = _normalize_skill_name(
578-
archive_skill_name or zip_path_obj.stem
579-
)
580-
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
581-
raise ValueError("Invalid skill name.")
582-
skill_name = archive_hint
583-
else:
584-
top_dirs = {
585-
PurePosixPath(name).parts[0] for name in file_names if name.strip()
586-
}
587-
if len(top_dirs) != 1:
588-
raise ValueError(
589-
"Zip archive must contain a single top-level folder."
590-
)
591-
archive_root_name = next(iter(top_dirs))
592-
archive_root_name_normalized = _normalize_skill_name(archive_root_name)
593-
if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch(
594-
archive_root_name_normalized
595-
):
596-
raise ValueError("Invalid skill folder name.")
597-
if archive_skill_name:
598-
if not _SKILL_NAME_RE.fullmatch(archive_skill_name):
599-
raise ValueError("Invalid skill name.")
600-
skill_name = archive_skill_name
601-
else:
602-
skill_name = archive_root_name_normalized
603-
604578
for name in names:
605579
if not name:
606580
continue
@@ -609,42 +583,117 @@ def install_skill_from_zip(
609583
parts = PurePosixPath(name).parts
610584
if ".." in parts:
611585
raise ValueError("Zip archive contains invalid relative paths.")
612-
if (not root_mode) and parts and parts[0] != archive_root_name:
613-
raise ValueError(
614-
"Zip archive contains unexpected top-level entries."
615-
)
616586

617-
if root_mode:
618-
if "SKILL.md" not in file_names and "skill.md" not in file_names:
619-
raise ValueError("SKILL.md not found in the skill folder.")
620-
else:
621-
if (
622-
f"{archive_root_name}/SKILL.md" not in file_names
623-
and f"{archive_root_name}/skill.md" not in file_names
624-
):
625-
raise ValueError("SKILL.md not found in the skill folder.")
587+
if not root_mode and not overwrite:
588+
top_dirs = {PurePosixPath(n).parts[0] for n in file_names if n.strip()}
589+
conflict_dirs: list[str] = []
590+
for src_dir_name in top_dirs:
591+
if (
592+
f"{src_dir_name}/SKILL.md" not in file_names
593+
and f"{src_dir_name}/skill.md" not in file_names
594+
):
595+
continue
596+
597+
candidate_name = _normalize_skill_name(src_dir_name)
598+
if not candidate_name or not _SKILL_NAME_RE.fullmatch(
599+
candidate_name
600+
):
601+
continue
602+
603+
if archive_skill_name and len(top_dirs) == 1:
604+
target_name = archive_skill_name
605+
else:
606+
target_name = candidate_name
607+
608+
dest_dir = Path(self.skills_root) / target_name
609+
if dest_dir.exists():
610+
conflict_dirs.append(str(dest_dir))
611+
612+
if conflict_dirs:
613+
raise FileExistsError(
614+
"One or more skills from the archive already exist and "
615+
"overwrite=False. No skills were installed. Conflicting "
616+
f"paths: {', '.join(conflict_dirs)}"
617+
)
626618

627619
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
628620
for member in zf.infolist():
629621
member_name = member.filename.replace("\\", "/")
630622
if not member_name or _is_ignored_zip_entry(member_name):
631623
continue
632624
zf.extract(member, tmp_dir)
633-
src_dir = (
634-
Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name
635-
)
636-
normalized_path = _normalize_skill_markdown_path(src_dir)
637-
if normalized_path is None:
638-
raise ValueError("SKILL.md not found in the skill folder.")
639-
_normalize_skill_markdown_path(src_dir)
640-
if not src_dir.exists():
641-
raise ValueError("Skill folder not found after extraction.")
642-
dest_dir = Path(self.skills_root) / skill_name
643-
if dest_dir.exists():
644-
if not overwrite:
645-
raise FileExistsError("Skill already exists.")
646-
shutil.rmtree(dest_dir)
647-
shutil.move(str(src_dir), str(dest_dir))
648-
649-
self.set_skill_active(skill_name, True)
650-
return skill_name
625+
626+
if root_mode:
627+
archive_hint = _normalize_skill_name(
628+
archive_skill_name or zip_path_obj.stem
629+
)
630+
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
631+
raise ValueError("Invalid skill name.")
632+
skill_name = archive_hint
633+
634+
src_dir = Path(tmp_dir)
635+
normalized_path = _normalize_skill_markdown_path(src_dir)
636+
if normalized_path is None:
637+
raise ValueError(
638+
"SKILL.md not found in the root of the zip archive."
639+
)
640+
641+
dest_dir = Path(self.skills_root) / skill_name
642+
if dest_dir.exists() and overwrite:
643+
shutil.rmtree(dest_dir)
644+
elif dest_dir.exists() and not overwrite:
645+
raise FileExistsError(f"Skill {skill_name} already exists.")
646+
647+
shutil.move(str(src_dir), str(dest_dir))
648+
self.set_skill_active(skill_name, True)
649+
installed_skills.append(skill_name)
650+
651+
else:
652+
top_dirs = {
653+
PurePosixPath(n).parts[0] for n in file_names if n.strip()
654+
}
655+
656+
for archive_root_name in top_dirs:
657+
archive_root_name_normalized = _normalize_skill_name(
658+
archive_root_name
659+
)
660+
661+
if (
662+
f"{archive_root_name}/SKILL.md" not in file_names
663+
and f"{archive_root_name}/skill.md" not in file_names
664+
):
665+
continue
666+
667+
if archive_root_name in {".", "..", ""} or not (
668+
_SKILL_NAME_RE.fullmatch(archive_root_name_normalized)
669+
):
670+
continue
671+
672+
if archive_skill_name and len(top_dirs) == 1:
673+
skill_name = archive_skill_name
674+
else:
675+
skill_name = archive_root_name_normalized
676+
677+
src_dir = Path(tmp_dir) / archive_root_name
678+
normalized_path = _normalize_skill_markdown_path(src_dir)
679+
if normalized_path is None:
680+
continue
681+
682+
dest_dir = Path(self.skills_root) / skill_name
683+
if dest_dir.exists():
684+
if not overwrite:
685+
raise FileExistsError(
686+
f"Skill {skill_name} already exists."
687+
)
688+
shutil.rmtree(dest_dir)
689+
690+
shutil.move(str(src_dir), str(dest_dir))
691+
self.set_skill_active(skill_name, True)
692+
installed_skills.append(skill_name)
693+
694+
if not installed_skills:
695+
raise ValueError(
696+
"No valid SKILL.md found in any folder of the zip archive."
697+
)
698+
699+
return ", ".join(installed_skills)

0 commit comments

Comments
 (0)