diff --git a/paperforge/config.py b/paperforge/config.py index 8f634566..403c34b8 100644 --- a/paperforge/config.py +++ b/paperforge/config.py @@ -55,6 +55,7 @@ DEFAULT_CONFIG: dict[str, str] = { "base_dir": "Bases", "skill_dir": ".opencode/skills", "command_dir": ".opencode/command", + "agent_platform": "opencode", } # Environment variable name map — maps config key to PAPERFORGE_* env var diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index b443e80a..edab7739 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -1475,6 +1475,43 @@ class PaperForgeStatusView extends ItemView { modal.open(); return; } + // Pure JS: Copy Context (single paper) — uses in-memory entry, no subprocess + if (a.id === 'paperforge-copy-context') { + let entry = this._currentPaperEntry; + if (!entry) { + const file = this.app.workspace.getActiveFile(); + if (file) { + const cache = this.app.metadataCache.getFileCache(file); + const key = cache?.frontmatter?.zotero_key; + if (key) entry = this._findEntry(key); + } + } + if (entry) { + navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => { + this._showMessage('[OK] ' + (entry.zotero_key || '') + ' copied to clipboard', 'ok'); + new Notice('[OK] Paper context copied to clipboard', 4000); + }).catch(e => { + this._showMessage('[!!] Clipboard failed: ' + e.message, 'error'); + }); + } else { + this._showMessage('[!!] No paper entry found', 'error'); + new Notice('[!!] Open a paper note or select one in the dashboard first', 5000); + } + return; + } + + // Pure JS: Copy Collection Context — uses cached index, no subprocess + if (a.id === 'paperforge-copy-collection-context') { + const items = this._cachedItems || []; + navigator.clipboard.writeText(JSON.stringify(items, null, 2)).then(() => { + this._showMessage('[OK] ' + items.length + ' entries copied to clipboard', 'ok'); + new Notice('[OK] ' + items.length + ' papers copied to clipboard', 4000); + }).catch(e => { + this._showMessage('[!!] Clipboard failed: ' + e.message, 'error'); + }); + return; + } + // Guard: disabled actions show coming-soon notice if (a.disabled) { new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000); diff --git a/paperforge/plugin/src/testable.js b/paperforge/plugin/src/testable.js index 8cbef65d..891f0e9b 100644 --- a/paperforge/plugin/src/testable.js +++ b/paperforge/plugin/src/testable.js @@ -175,7 +175,6 @@ const ACTIONS = [ desc: "Copy this paper\u2019s canonical index entry JSON to clipboard for AI use", icon: "\u2139", cmd: "context", - needsKey: true, okMsg: "Context copied", }, { @@ -184,7 +183,6 @@ const ACTIONS = [ desc: "Copy canonical index entries for all visible papers to clipboard", icon: "\u2261", cmd: "context", - needsFilter: true, okMsg: "Collection context copied", }, ]; diff --git a/paperforge/plugin/tests/commands.test.mjs b/paperforge/plugin/tests/commands.test.mjs index c9d3b680..b2650f1a 100644 --- a/paperforge/plugin/tests/commands.test.mjs +++ b/paperforge/plugin/tests/commands.test.mjs @@ -26,11 +26,11 @@ describe('ACTIONS', () => { it('repair action is enabled (no disabled flag)', () => { expect(ACTIONS.find(a => a.id === 'paperforge-repair')?.disabled).toBeUndefined(); }); - it('copy-context action has needsKey', () => { - expect(ACTIONS.find(a => a.id === 'paperforge-copy-context')?.needsKey).toBe(true); + it('copy-context action has no needsKey (pure JS)', () => { + expect(ACTIONS.find(a => a.id === 'paperforge-copy-context')?.needsKey).toBeUndefined(); }); - it('copy-collection-context action has needsFilter', () => { - expect(ACTIONS.find(a => a.id === 'paperforge-copy-collection-context')?.needsFilter).toBe(true); + it('copy-collection-context action has no needsFilter (pure JS)', () => { + expect(ACTIONS.find(a => a.id === 'paperforge-copy-collection-context')?.needsFilter).toBeUndefined(); }); }); diff --git a/paperforge/services/skill_deploy.py b/paperforge/services/skill_deploy.py new file mode 100644 index 00000000..f821baf3 --- /dev/null +++ b/paperforge/services/skill_deploy.py @@ -0,0 +1,261 @@ +"""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), + ("", 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. + + 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, + } diff --git a/paperforge/setup_wizard.py b/paperforge/setup_wizard.py index 082a2fb6..f6f58a8a 100644 --- a/paperforge/setup_wizard.py +++ b/paperforge/setup_wizard.py @@ -846,60 +846,31 @@ def headless_setup( _copy_file_incremental(mod_src, pf_path / "worker/scripts" / mod) print(f" [OK] worker scripts") - # Deploy skills based on agent format - fmt = agent_config.get("format", "skill_directory") - prefix = agent_config.get("prefix", "/") - imported_skills = [] + # Deploy skills via shared service (single source of truth) + from paperforge.services.skill_deploy import deploy_skills as _deploy_skills_service - if fmt == "flat_command": - imported_skills = _deploy_flat_command( - vault, - agent_config["command_dir"], - repo_root, - system_dir, - resources_dir, - literature_dir, - base_dir, - skill_dir, - ) - # OpenCode also needs the skill directory (ld_deep.py, prompt, chart-reading) - imported_skills += _deploy_skill_directory( - vault, - skill_dir, - repo_root, - system_dir, - resources_dir, - literature_dir, - base_dir, - prefix, - ) - elif fmt == "rules_file": - imported_skills = _deploy_rules_file( - vault, - agent_config["skill_dir"], - repo_root, - system_dir, - resources_dir, - literature_dir, - base_dir, - skill_dir, - ) - else: - # skill_directory (default) - imported_skills = _deploy_skill_directory( - vault, - skill_dir, - repo_root, - system_dir, - resources_dir, - literature_dir, - base_dir, - prefix, - ) + skill_result = _deploy_skills_service( + vault=vault, + agent_key=agent_key, + system_dir=system_dir, + resources_dir=resources_dir, + literature_dir=literature_dir, + base_dir=base_dir, + overwrite=False, + ) + imported_skills = skill_result["skills"] + for err in skill_result.get("errors", []): + print(f" [WARN] {err}") if imported_skills: print(f" [OK] {len(imported_skills)} skill(s): {', '.join(imported_skills)}") + # AGENTS.md + if skill_result["agents_md"]: + print(f" [OK] AGENTS.md") + else: + print(f" [WARN] AGENTS.md source not found; skipping") + # Create agent config file if defined (e.g., Claude skills.json) config_file = agent_config.get("config_file") if config_file: @@ -937,24 +908,6 @@ def headless_setup( else: print(f" [WARN] Plugin source not found: {plugin_src}") - # AGENTS.md - agents_src = repo_root / "AGENTS.md" - agents_dst = vault / "AGENTS.md" - if agents_src.exists(): - text = agents_src.read_text(encoding="utf-8") - for old, new in [ - ("", system_dir), - ("", resources_dir), - ("", literature_dir), - ("", base_dir), - ("", skill_dir), - ]: - text = text.replace(old, new) - _write_text_incremental(agents_dst, text) - print(f" [OK] AGENTS.md") - else: - print(f" [WARN] AGENTS.md source not found; skipping") - # ========================================================================= # Phase 5: Create config files # ========================================================================= diff --git a/paperforge/worker/update.py b/paperforge/worker/update.py index b3385123..edd95970 100644 --- a/paperforge/worker/update.py +++ b/paperforge/worker/update.py @@ -34,6 +34,31 @@ 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" @@ -237,6 +262,32 @@ def update_via_zip(vault: Path) -> bool: shutil.rmtree(tmp, ignore_errors=True) +def _deploy_all_skills(vault: Path) -> None: + """Deploy latest skills and AGENTS.md to vault after update.""" + try: + from paperforge.services.skill_deploy import deploy_skills + from paperforge.config import load_vault_config + + config = load_vault_config(vault) + result = deploy_skills( + vault=vault, + agent_key=config.get("agent_platform", "opencode"), + 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"])) + if result["agents_md"]: + logger.info("已更新 AGENTS.md") + for err in result.get("errors", []): + logger.warning("Skill 部署警告: %s", err) + except Exception as e: + logger.warning("Skill 部署失败(非致命): %s", e) + + def run_update(vault: Path) -> int: """运行更新检查与安装""" try: @@ -262,6 +313,7 @@ def run_update(vault: Path) -> int: needs = remote != local if not needs: _sync_obsidian_plugin(vault) + _deploy_all_skills(vault) logger.info("当前已是最新版本") return 0 logger.info("发现新版本: %s -> %s", local, remote) @@ -298,6 +350,7 @@ def run_update(vault: Path) -> int: if success: _sync_obsidian_plugin(vault) + _deploy_all_skills(vault) logger.info("更新完成!请重启 Obsidian") return 0 if success else 1