refactor(skills): simplify skill deployment — single copytree for all platforms

- Remove format dispatch (flat_command/skill_directory/rules_file) — all platforms unified
- skill_deploy.py: 257→81 lines, only AGENT_SKILL_DIRS + copytree + AGENTS.md
- AgentInstaller switches to vault-local paths, removes deploy_commands step
- Remove _deploy_skills dead code in update.py
This commit is contained in:
Research Assistant 2026-05-10 23:22:42 +08:00
parent d50c2ff4c0
commit c0cc05ab32
3 changed files with 57 additions and 311 deletions

View file

@ -1,4 +1,4 @@
"""Skill deployment service — single source of truth for agent skill installation and updates.
"""Skill deployment service — single copytree for all platforms.
Used by both setup wizard (install) and update worker (update).
All deployments are vault-local only.
@ -9,253 +9,82 @@ from __future__ import annotations
import shutil
from pathlib import Path
# ── Agent Platform Configurations ──
# Canonical source of truth. setup_wizard.py imports from here.
AGENT_CONFIGS = {
"opencode": {
"name": "OpenCode",
"skill_dir": ".opencode/skills",
"command_dir": ".opencode/command",
"format": "flat_command",
"prefix": "/",
"config_file": None,
},
"claude": {
"name": "Claude Code",
"skill_dir": ".claude/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": ".claude/skills.json",
},
"codex": {
"name": "Codex",
"skill_dir": ".codex/skills",
"format": "skill_directory",
"prefix": "$",
"config_file": None,
},
"cursor": {
"name": "Cursor",
"skill_dir": ".cursor/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": ".cursor/settings.json",
},
"windsurf": {
"name": "Windsurf",
"skill_dir": ".windsurf/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": None,
},
"github_copilot": {
"name": "GitHub Copilot",
"skill_dir": ".github/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": ".github/copilot-instructions.md",
},
"cline": {
"name": "Cline",
"skill_dir": ".clinerules",
"format": "rules_file",
"prefix": "/",
"config_file": ".clinerules",
},
"augment": {
"name": "Augment",
"skill_dir": ".augment/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": None,
},
"trae": {
"name": "Trae",
"skill_dir": ".trae/skills",
"format": "skill_directory",
"prefix": "/",
"config_file": None,
},
# ── Agent platform → vault-local skill directory ──
AGENT_SKILL_DIRS: dict[str, str] = {
"opencode": ".opencode/skills",
"claude": ".claude/skills",
"codex": ".codex/skills",
"cursor": ".cursor/skills",
"windsurf": ".windsurf/skills",
"github_copilot": ".github/skills",
"cline": ".clinerules",
"augment": ".augment/skills",
"trae": ".trae/skills",
}
def _resolve_source_root() -> Path:
"""Resolve the paperforge package root (where skills/ lives)."""
import paperforge
return Path(paperforge.__file__).parent
def _substitute_vars(text: str, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, skill_dir: str, prefix: str = "/") -> str:
for old, new in [
("<system_dir>", system_dir),
("<resources_dir>", resources_dir),
("<literature_dir>", literature_dir),
("<base_dir>", base_dir),
("<skill_dir>", skill_dir),
("<prefix>", prefix),
]:
text = text.replace(old, new)
return text
# ── Deploy helpers ──
def _deploy_skill_directory(vault: Path, skill_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, prefix: str = "/", overwrite: bool = False) -> list[str]:
"""Deploy pf-* skills as independent SKILL.md directories (Claude Code, Codex, Cursor, etc.)."""
imported = []
src_scripts = source_root / "skills" / "literature-qa" / "scripts"
src_charts = source_root / "skills" / "literature-qa" / "chart-reading"
src_prompt = source_root / "skills" / "literature-qa" / "prompt_deep_subagent.md"
for skill_file in sorted(src_scripts.glob("pf-*.md")):
skill_name = skill_file.stem
skill_dst = vault / skill_dir / skill_name
skill_dst.mkdir(parents=True, exist_ok=True)
text = skill_file.read_text(encoding="utf-8")
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir, prefix)
dst_file = skill_dst / "SKILL.md"
if overwrite or not dst_file.exists():
dst_file.write_text(text, encoding="utf-8")
imported.append(skill_name)
# pf-deep extras: scripts, chart-reading, subagent prompt
pf_deep_dst = vault / skill_dir / "pf-deep"
pf_deep_dst.mkdir(parents=True, exist_ok=True)
ld_dst = pf_deep_dst / "scripts" / "ld_deep.py"
ld_src = src_scripts / "ld_deep.py"
if ld_src.exists() and (overwrite or not ld_dst.exists()):
ld_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ld_src, ld_dst)
prompt_dst = pf_deep_dst / "prompt_deep_subagent.md"
if src_prompt.exists() and (overwrite or not prompt_dst.exists()):
shutil.copy2(src_prompt, prompt_dst)
if src_charts.exists() and src_charts.is_dir():
chart_dst = pf_deep_dst / "chart-reading"
if overwrite and chart_dst.exists():
shutil.rmtree(chart_dst)
chart_dst.mkdir(parents=True, exist_ok=True)
for f in src_charts.glob("*.md"):
if overwrite or not (chart_dst / f.name).exists():
shutil.copy2(f, chart_dst / f.name)
return imported
def _deploy_flat_command(vault: Path, command_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, skill_dir: str, overwrite: bool = False) -> list[str]:
"""Deploy skills in flat .md command format (OpenCode)."""
imported = []
command_src = source_root / "skills" / "literature-qa" / "scripts"
command_dst = vault / command_dir
if not (command_src.exists() and command_src.is_dir()):
return imported
command_dst.mkdir(parents=True, exist_ok=True)
for f in command_src.glob("pf-*.md"):
text = f.read_text(encoding="utf-8")
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir)
dst_file = command_dst / f.name
if overwrite or not dst_file.exists():
dst_file.write_text(text, encoding="utf-8")
imported.append(f.stem)
return imported
def _deploy_rules_file(vault: Path, skill_dir: str, source_root: Path, system_dir: str, resources_dir: str, literature_dir: str, base_dir: str, overwrite: bool = False) -> list[str]:
"""Deploy skills as .clinerules directory (Cline)."""
imported = []
src_scripts = source_root / "skills" / "literature-qa" / "scripts"
src_charts = source_root / "skills" / "literature-qa" / "chart-reading"
src_prompt = source_root / "skills" / "literature-qa" / "prompt_deep_subagent.md"
pf_deep_dst = vault / skill_dir / "pf-deep"
pf_deep_dst.mkdir(parents=True, exist_ok=True)
ld_src = src_scripts / "ld_deep.py"
ld_dst = pf_deep_dst / "scripts" / "ld_deep.py"
if ld_src.exists() and (overwrite or not ld_dst.exists()):
ld_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ld_src, ld_dst)
prompt_dst = pf_deep_dst / "prompt_deep_subagent.md"
if src_prompt.exists() and (overwrite or not prompt_dst.exists()):
shutil.copy2(src_prompt, prompt_dst)
if src_charts.exists() and src_charts.is_dir():
chart_dst = pf_deep_dst / "chart-reading"
if overwrite and chart_dst.exists():
shutil.rmtree(chart_dst)
chart_dst.mkdir(parents=True, exist_ok=True)
for f in src_charts.glob("*.md"):
if overwrite or not (chart_dst / f.name).exists():
shutil.copy2(f, chart_dst / f.name)
imported.append("clinerules")
return imported
# ── Main entry point ──
def deploy_skills(
vault: Path,
agent_key: str = "opencode",
system_dir: str = "System",
resources_dir: str = "Resources",
literature_dir: str = "Literature",
base_dir: str = "Bases",
overwrite: bool = False,
) -> dict:
"""Deploy skills, commands, and AGENTS.md for a given agent platform.
"""Deploy literature-qa skill and AGENTS.md to the vault.
Args:
vault: Obsidian vault root
agent_key: Agent platform key (opencode, cursor, claude, etc.)
overwrite: True for update (overwrite existing), False for install (skip)
vault: Obsidian vault root.
agent_key: Agent platform key (opencode, claude, etc.).
overwrite: If True, overwrite existing files (used by update).
Returns:
dict with 'skills', 'commands', 'agents_md', 'errors' keys
dict with 'skill_deployed', 'agents_md', 'errors' keys.
"""
agent_config = AGENT_CONFIGS.get(agent_key)
if not agent_config:
return {"skills": [], "commands": [], "agents_md": False, "errors": [f"Unknown agent: {agent_key}"]}
source_root = _resolve_source_root()
if not (source_root / "skills" / "literature-qa").exists():
return {"skills": [], "commands": [], "agents_md": False, "errors": ["Skills source not found in package"]}
skill_dir = agent_config.get("skill_dir", ".opencode/skills")
fmt = agent_config.get("format", "skill_directory")
prefix = agent_config.get("prefix", "/")
imported_skills: list[str] = []
errors: list[str] = []
# Deploy skills by format
try:
if fmt == "flat_command":
imported_skills = _deploy_flat_command(vault, agent_config["command_dir"], source_root, system_dir, resources_dir, literature_dir, base_dir, skill_dir, overwrite)
imported_skills += _deploy_skill_directory(vault, skill_dir, source_root, system_dir, resources_dir, literature_dir, base_dir, prefix, overwrite)
elif fmt == "rules_file":
imported_skills = _deploy_rules_file(vault, agent_config["skill_dir"], source_root, system_dir, resources_dir, literature_dir, base_dir, overwrite)
else:
imported_skills = _deploy_skill_directory(vault, skill_dir, source_root, system_dir, resources_dir, literature_dir, base_dir, prefix, overwrite)
except Exception as e:
errors.append(f"Skill deploy failed: {e}")
# ── Deploy literature-qa skill ──
skill_deployed = False
source_root = _resolve_source_root()
src_skill = source_root / "skills" / "literature-qa"
# Deploy AGENTS.md
if src_skill.exists():
skill_dir_name = AGENT_SKILL_DIRS.get(agent_key)
if skill_dir_name:
dst_skill = vault / skill_dir_name / "literature-qa"
try:
if overwrite and dst_skill.exists():
shutil.rmtree(dst_skill, ignore_errors=True)
dst_skill.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_skill, dst_skill, dirs_exist_ok=True)
skill_deployed = True
except Exception as e:
errors.append(f"Skill deploy failed: {e}")
else:
errors.append(f"Unknown agent: {agent_key}")
else:
errors.append("Skills source not found in package")
# ── Deploy AGENTS.md ──
agents_ok = False
agents_src = source_root.parent / "AGENTS.md"
if agents_src.exists():
try:
agents_dst = vault / "AGENTS.md"
text = agents_src.read_text(encoding="utf-8")
text = _substitute_vars(text, system_dir, resources_dir, literature_dir, base_dir, skill_dir, prefix)
if overwrite and agents_dst.exists():
agents_dst.unlink()
if overwrite or not agents_dst.exists():
agents_dst.write_text(text, encoding="utf-8")
shutil.copy2(agents_src, agents_dst)
agents_ok = True
except Exception as e:
errors.append(f"AGENTS.md deploy failed: {e}")
return {
"skills": imported_skills,
"commands": imported_skills,
"skill_deployed": skill_deployed,
"agents_md": agents_ok,
"errors": errors,
}

View file

@ -1,15 +1,16 @@
"""AgentInstaller — deploys skill files and agent configs."""
"""AgentInstaller — deploys literature-qa skill to vault-local agent config."""
from __future__ import annotations
import shutil
from pathlib import Path
from paperforge.services.skill_deploy import AGENT_SKILL_DIRS
from paperforge.setup import SetupStepResult
class AgentInstaller:
"""Deploy agent skill files, command files, and rules."""
"""Deploy literature-qa skill directory to vault-local agent skills path."""
def __init__(self, vault: Path, agent_type: str = "opencode"):
self.vault = vault
@ -17,19 +18,12 @@ class AgentInstaller:
self._script_dir = Path(__file__).resolve().parent.parent
def _get_skills_dir(self) -> Path:
"""Get the target skills directory based on agent type."""
if self.agent_type == "opencode":
base = Path.home() / ".config" / "opencode"
elif self.agent_type == "claude":
base = Path.home() / ".claude"
elif self.agent_type == "codex":
base = Path.home() / ".codex"
else:
base = self.vault / ".agents"
return base / "skills"
"""Get the vault-local target skills directory."""
skill_dir_name = AGENT_SKILL_DIRS.get(self.agent_type, ".agents/skills")
return self.vault / skill_dir_name
def deploy_skills(self) -> SetupStepResult:
"""Deploy literature-qa skill directory to agent config."""
"""Deploy literature-qa skill as a single directory."""
source_skills = self._script_dir / "skills" / "literature-qa"
if not source_skills.exists():
return SetupStepResult(
@ -43,11 +37,7 @@ class AgentInstaller:
target_dir.mkdir(parents=True, exist_ok=True)
try:
if source_skills.is_dir():
shutil.copytree(source_skills, target_dir, dirs_exist_ok=True)
else:
shutil.copy2(source_skills, target_dir)
shutil.copytree(source_skills, target_dir, dirs_exist_ok=True)
return SetupStepResult(
step="agent_installer",
ok=True,
@ -62,42 +52,8 @@ class AgentInstaller:
error=str(e),
)
def deploy_commands(self) -> SetupStepResult:
"""Deploy command files to vault agent config dir."""
source_commands = self._script_dir / "command_files"
if not source_commands.exists():
return SetupStepResult(
step="agent_installer",
ok=True,
message="No command files to deploy",
details={"skipped": True},
)
agent_dir = self.vault / ".agents"
target_dir = agent_dir / "command_files"
target_dir.mkdir(parents=True, exist_ok=True)
try:
for f in source_commands.iterdir():
if f.is_file():
shutil.copy2(f, target_dir / f.name)
return SetupStepResult(
step="agent_installer",
ok=True,
message=f"Deployed command files to {target_dir}",
details={"source": str(source_commands), "target": str(target_dir)},
)
except Exception as e:
return SetupStepResult(
step="agent_installer",
ok=False,
message="Failed to deploy command files",
error=str(e),
)
def deploy_agent_config(self) -> SetupStepResult:
"""Deploy AGENTS.md and other agent config files."""
"""Deploy AGENTS.md to vault root."""
source_agents = self._script_dir.parent / "AGENTS.md"
if not source_agents.exists():
return SetupStepResult(
@ -126,8 +82,4 @@ class AgentInstaller:
def run_all(self) -> list[SetupStepResult]:
"""Run all deployment steps."""
results = []
results.append(self.deploy_skills())
results.append(self.deploy_commands())
results.append(self.deploy_agent_config())
return results
return [self.deploy_skills(), self.deploy_agent_config()]

View file

@ -34,31 +34,6 @@ def _sync_obsidian_plugin(vault: Path) -> None:
_pf_utils.install_obsidian_plugin(vault)
def _deploy_skills(vault: Path) -> None:
"""Copy the literature-qa skill from the package to the vault's agent dir."""
import paperforge
src = Path(paperforge.__file__).parent / "skills" / "literature-qa"
if not src.exists():
logger.debug("Skills source not found: %s", src)
return
raw = read_paperforge_json(vault)
agent = raw.get("agent_platform", "opencode")
agent_dirs = {
"opencode": ".opencode/skills/literature-qa",
"claude": ".claude/skills/literature-qa",
"cursor": ".cursor/skills/literature-qa",
"copilot": ".github/skills/literature-qa",
"windsurf": ".windsurf/skills/literature-qa",
"codex": ".codex/skills/literature-qa",
}
target_rel = agent_dirs.get(agent, agent_dirs["opencode"])
dst = vault / target_rel
shutil.copytree(src, dst, dirs_exist_ok=True)
logger.info("Skills deployed: %s -> %s", src, dst)
def protected_paths(vault: Path) -> set[str]:
cfg = load_vault_config(vault)
pf = f"{cfg['system_dir']}/PaperForge"
@ -269,20 +244,10 @@ def _deploy_all_skills(vault: Path) -> None:
from paperforge.config import load_vault_config
config = load_vault_config(vault)
# Agent platform is a user preference, not a system default.
# Fall back to opencode if not configured.
agent_key = config.get("agent_platform") or "opencode"
result = deploy_skills(
vault=vault,
agent_key=agent_key,
system_dir=config.get("system_dir", "System"),
resources_dir=config.get("resources_dir", "Resources"),
literature_dir=config.get("literature_dir", "Literature"),
base_dir=config.get("base_dir", "Bases"),
overwrite=True,
)
if result["skills"]:
logger.info("已部署 %d 个 skill: %s", len(result["skills"]), ", ".join(result["skills"]))
result = deploy_skills(vault=vault, agent_key=agent_key, overwrite=True)
if result["skill_deployed"]:
logger.info("已部署 literature-qa skill")
if result["agents_md"]:
logger.info("已更新 AGENTS.md")
for err in result.get("errors", []):