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:
csh
2026-05-16 13:16:32 +08:00
co-authored by Claude Opus 4.6
parent 9f8b6b5369
commit 6ec9a45a83
4 changed files with 133 additions and 3 deletions
+49 -1
View File
@@ -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": "## TSLtsl/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":