diff --git a/playbook.toml.example b/playbook.toml.example index e1c7f00e..11922e5a 100644 --- a/playbook.toml.example +++ b/playbook.toml.example @@ -40,7 +40,8 @@ [install_skills] # mode = "list" # list|all # skills = ["brainstorming"] # mode=list 时必填 -# agents_home = "~/.agents" # Codex CLI;Claude Code 用 "~/.claude";默认 ~/.agents +# agents_home = "~/.agents" # 部署目标;Codex CLI 用 "~/.agents",Claude Code 用 "~/.claude";默认 ~/.agents +# skill_link = "~/.claude" # 可选:在此目录下创建 skills/ 软链接指向 agents_home/skills/ # no_backup = false # 可选:跳过备份,直接删除旧 skill 后重装 [format_md] diff --git a/scripts/playbook.py b/scripts/playbook.py index 6bdbfdd5..089e7144 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -22,7 +22,7 @@ ORDER = [ ] SCRIPT_DIR = Path(__file__).resolve().parent PLAYBOOK_ROOT = SCRIPT_DIR.parent -PATH_CONFIG_KEYS = {"project_root", "deploy_root", "agents_home", "codex_home"} +PATH_CONFIG_KEYS = {"project_root", "deploy_root", "agents_home", "codex_home", "skill_link"} DOCS_INDEX_SECTION_HEADINGS = { "common": "## 跨语言(common)", "tsl": "## TSL(tsl/tsf)", @@ -1244,9 +1244,57 @@ def install_skills_action(config: dict, context: dict) -> int: tag = " [thirdparty]" if origin == "thirdparty" else "" log(f"Installed: {name}{tag}") + skill_link_raw = config.get("skill_link") + if skill_link_raw: + skill_link_home = Path(str(skill_link_raw)).expanduser() + if not skill_link_home.is_absolute(): + skill_link_home = (context["project_root"] / skill_link_home).resolve() + _create_skills_symlink(skill_link_home / "skills", skills_dst_root) + return 0 +def _is_junction(path: Path) -> bool: + if sys.platform != "win32": + return False + try: + import ctypes.wintypes + attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) + return attrs != -1 and bool(attrs & 0x400) + except Exception: + return False + + +def _create_skills_symlink(link_path: Path, target_path: Path) -> None: + if link_path.is_symlink() or _is_junction(link_path): + if link_path.resolve() == target_path.resolve(): + log(f"Symlink already up to date: {link_path}") + return + if link_path.is_symlink(): + link_path.unlink() + elif _is_junction(link_path): + link_path.rmdir() + elif link_path.exists(): + log(f"Skip symlink: {link_path} exists and is not a symlink") + return + ensure_dir(link_path.parent) + try: + link_path.symlink_to(target_path, target_is_directory=True) + log(f"Created symlink: {link_path} -> {target_path}") + except OSError: + if sys.platform == "win32": + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link_path), str(target_path)], + capture_output=True, + ) + if result.returncode == 0: + log(f"Created junction: {link_path} -> {target_path}") + else: + log(f"Warning: could not create junction {link_path}") + else: + log(f"Warning: could not create symlink {link_path}") + + def format_md_action(config: dict, context: dict) -> int: tool = str(config.get("tool", "prettier")).lower() if tool != "prettier": diff --git a/templates/README.md b/templates/README.md index 56000c57..95532951 100644 --- a/templates/README.md +++ b/templates/README.md @@ -231,7 +231,7 @@ project/ 当你需要修改代码但暂时不运行测试时,可用以下提示词生成“可执行且不失败”的实施计划: ```text -你是 Codex。先使用 $brainstorming。 +先使用 $brainstorming。 目标:修改 <模块/功能>,细节如下:<你的需求> 约束: - 不跑任何测试(test/ci),但允许做可通过的局部验证(格式化/静态检查/人工 diff)。 diff --git a/tests/cli/test_playbook_cli.py b/tests/cli/test_playbook_cli.py index ac7d739f..244d9f75 100644 --- a/tests/cli/test_playbook_cli.py +++ b/tests/cli/test_playbook_cli.py @@ -583,5 +583,86 @@ project_name = "Demo" self.assertEqual(claude_md.read_text(encoding="utf-8"), original) + def test_install_skills_creates_symlink_when_skill_link_configured(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + agents_home = root / "agents" + link_home = root / "claude" + config_body = f""" +[playbook] +project_root = "{tmp_dir}" +deploy_root = "{CUSTOM_DEPLOY_ROOT}" + +[install_skills] +agents_home = "{agents_home}" +skill_link = "{link_home}" +mode = "list" +skills = ["commit-message"] +""" + config_path = root / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + skills_dst = agents_home / "skills" + link_path = link_home / "skills" + self.assertTrue(skills_dst.is_dir()) + self.assertTrue(link_path.is_dir(), "link_path should be accessible as dir") + self.assertEqual(link_path.resolve(), skills_dst.resolve()) + self.assertTrue((link_path / "commit-message" / "SKILL.md").is_file()) + + def test_install_skills_symlink_is_idempotent(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + agents_home = root / "agents" + link_home = root / "claude" + config_body = f""" +[playbook] +project_root = "{tmp_dir}" +deploy_root = "{CUSTOM_DEPLOY_ROOT}" + +[install_skills] +agents_home = "{agents_home}" +skill_link = "{link_home}" +mode = "list" +skills = ["commit-message"] +no_backup = true +""" + config_path = root / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + run_cli("-config", str(config_path)) + result = run_cli("-config", str(config_path)) + + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + self.assertTrue((link_home / "skills").is_dir()) + + def test_install_skills_no_symlink_when_skill_link_absent(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + agents_home = root / "agents" + config_body = f""" +[playbook] +project_root = "{tmp_dir}" +deploy_root = "{CUSTOM_DEPLOY_ROOT}" + +[install_skills] +agents_home = "{agents_home}" +mode = "list" +skills = ["commit-message"] +""" + config_path = root / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + self.assertFalse(any( + p.is_symlink() for p in (agents_home / "skills").iterdir() + if p.is_symlink() + ) if (agents_home / "skills").exists() else False) + + if __name__ == "__main__": unittest.main()