diff --git a/paperforge/services/skill_deploy.py b/paperforge/services/skill_deploy.py index f821baf3..66076b09 100644 --- a/paperforge/services/skill_deploy.py +++ b/paperforge/services/skill_deploy.py @@ -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), - ("", resources_dir), - ("", literature_dir), - ("", base_dir), - ("", skill_dir), - ("", 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, } diff --git a/paperforge/setup/agent.py b/paperforge/setup/agent.py index 156e7d5f..f6b4bd57 100644 --- a/paperforge/setup/agent.py +++ b/paperforge/setup/agent.py @@ -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()] diff --git a/paperforge/worker/update.py b/paperforge/worker/update.py index 0f4bcfe9..a92742cf 100644 --- a/paperforge/worker/update.py +++ b/paperforge/worker/update.py @@ -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", []):