lllin000_PaperForge/paperforge/services/skill_deploy.py
Research Assistant f885adae48 feat: skill auto-deploy in update + Copy Context as pure JS + shared skill_deploy service
- New services/skill_deploy.py: single source of truth for agent skill deployment
  - AGENT_CONFIGS with 9 platforms, all vault-local
  - deploy_skills() with install/update mode (overwrite flag)
  - Used by both setup wizard and update worker
- update.py: _deploy_all_skills() after pip/git/zip update
- setup_wizard.py: delegates skill deploy to shared service
- config.py: add agent_platform default
- main.js: Copy Context + Copy Collection Context now pure JS
  - Uses in-memory _cachedItems / _currentPaperEntry
  - No subprocess spawn, no timeout, no JSON parse errors
- testable.js: remove needsKey/needsFilter from context actions
- commands.test.mjs: update assertions for removed flags
2026-05-10 12:33:23 +08:00

261 lines
9.9 KiB
Python

"""Skill deployment service — single source of truth for agent skill installation and updates.
Used by both setup wizard (install) and update worker (update).
All deployments are vault-local only.
"""
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,
},
}
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.
Args:
vault: Obsidian vault root
agent_key: Agent platform key (opencode, cursor, claude, etc.)
overwrite: True for update (overwrite existing), False for install (skip)
Returns:
dict with 'skills', 'commands', '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 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 or not agents_dst.exists():
agents_dst.write_text(text, encoding="utf-8")
agents_ok = True
except Exception as e:
errors.append(f"AGENTS.md deploy failed: {e}")
return {
"skills": imported_skills,
"commands": imported_skills,
"agents_md": agents_ok,
"errors": errors,
}