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
This commit is contained in:
Research Assistant 2026-05-10 12:33:23 +08:00
parent be54b81499
commit f885adae48
7 changed files with 376 additions and 73 deletions

View file

@ -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

View file

@ -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);

View file

@ -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",
},
];

View file

@ -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();
});
});

View file

@ -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>", 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,
}

View file

@ -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>", system_dir),
("<resources_dir>", resources_dir),
("<literature_dir>", literature_dir),
("<base_dir>", base_dir),
("<skill_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
# =========================================================================

View file

@ -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