✨ feat(skills): add skill_link symlink support + platform-agnostic prompt
- Add skill_link config option to create a symlink/junction from another platform's skills dir to agents_home/skills/ (e.g. ~/.claude -> ~/.agents) - On Windows, falls back to directory junction when symlink requires admin - Add _create_skills_symlink() with _is_junction() helper for Windows - Update playbook.toml.example with skill_link documentation - Fix templates/README.md prompt example to be platform-agnostic - Add 3 tests: symlink creation, idempotency, absence when unconfigured Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9f8b6b5369
commit
6ec9a45a83
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ project/
|
|||
当你需要修改代码但暂时不运行测试时,可用以下提示词生成“可执行且不失败”的实施计划:
|
||||
|
||||
```text
|
||||
你是 Codex。先使用 $brainstorming。
|
||||
先使用 $brainstorming。
|
||||
目标:修改 <模块/功能>,细节如下:<你的需求>
|
||||
约束:
|
||||
- 不跑任何测试(test/ci),但允许做可通过的局部验证(格式化/静态检查/人工 diff)。
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue