-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall_skills.py
More file actions
170 lines (149 loc) · 5.25 KB
/
Copy pathinstall_skills.py
File metadata and controls
170 lines (149 loc) · 5.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#!/usr/bin/env python3
"""Install the flat skills in this repository into an AI coding agent.
Each skill in ``skills/`` is a single flat directory containing a top-level
``SKILL.md`` (plus optional ``agents/``, ``scripts/``, ``references/``, and
``assets/``). Agents such as Claude Code load skills from a flat skills
directory and do not support a ``SKILL.md`` nested inside another skill, so
this installer copies whole skill directories into the target agent's skills
directory and refuses to install a skill that contains nested skills.
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SKILLS_ROOT = REPO_ROOT / "skills"
# Per-agent skills subdirectory, resolved against the user home (user scope) or
# a project root (project scope). Claude Code reads ~/.claude/skills and
# <project>/.claude/skills. Codex reads $HOME/.agents/skills and
# <repo>/.agents/skills, the Agent Skills open-standard location; see
# https://developers.openai.com/codex/skills.
AGENT_SKILLS_SUBDIR = {
"claude": Path(".claude") / "skills",
"codex": Path(".agents") / "skills",
}
def discover_skills(skills_root: Path = SKILLS_ROOT) -> list[Path]:
"""Return skill directories: immediate children with a top-level SKILL.md."""
return sorted(
child
for child in skills_root.iterdir()
if child.is_dir() and (child / "SKILL.md").is_file()
)
def find_nested_skills(skill_dir: Path) -> list[Path]:
"""Return any SKILL.md files below the skill's own top-level SKILL.md."""
return sorted(
path
for path in skill_dir.rglob("SKILL.md")
if path != skill_dir / "SKILL.md"
)
def resolve_dest(
agent: str | None, dest: str | None, scope: str = "user"
) -> Path:
if dest:
return Path(dest).expanduser()
if agent and agent in AGENT_SKILLS_SUBDIR:
base = Path.cwd() if scope == "project" else Path.home()
return base / AGENT_SKILLS_SUBDIR[agent]
raise SystemExit(
"error: specify --dest, or --agent from "
f"{sorted(AGENT_SKILLS_SUBDIR)}"
)
def install(
skills: list[Path],
dest_root: Path,
*,
force: bool,
dry_run: bool,
) -> int:
dest_root.mkdir(parents=True, exist_ok=True)
for skill_dir in skills:
target = dest_root / skill_dir.name
action = "would install" if dry_run else "installing"
print(f"{action}: {skill_dir.name} -> {target}")
if dry_run:
continue
if target.exists():
if not force:
print(
f" skipped: {target} already exists (use --force to replace)",
file=sys.stderr,
)
continue
shutil.rmtree(target)
shutil.copytree(skill_dir, target)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--agent",
choices=sorted(AGENT_SKILLS_SUBDIR),
help="Target agent; selects a default skills directory.",
)
parser.add_argument(
"--scope",
choices=("user", "project"),
default="user",
help="Install into the user home (default) or the current project root.",
)
parser.add_argument(
"--dest",
help="Explicit skills directory to install into (overrides --agent).",
)
parser.add_argument(
"--skills",
nargs="+",
metavar="NAME",
help="Install only these skills by name (default: all).",
)
parser.add_argument(
"--list",
action="store_true",
help="List discoverable skills and exit.",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace a skill directory that already exists at the target.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be installed without copying.",
)
args = parser.parse_args(argv)
available = discover_skills()
by_name = {skill.name: skill for skill in available}
if args.list:
for name in sorted(by_name):
print(name)
return 0
if args.skills:
missing = [name for name in args.skills if name not in by_name]
if missing:
print(f"error: unknown skills: {', '.join(missing)}", file=sys.stderr)
return 2
selected = [by_name[name] for name in args.skills]
else:
selected = available
nested_problems = {
skill.name: nested
for skill in selected
if (nested := find_nested_skills(skill))
}
if nested_problems:
print(
"error: these skills contain nested SKILL.md files and cannot be "
"installed flatly (split them into top-level skills first):",
file=sys.stderr,
)
for name, nested in nested_problems.items():
for path in nested:
print(f" {name}: {path.relative_to(SKILLS_ROOT)}", file=sys.stderr)
return 2
dest_root = resolve_dest(args.agent, args.dest, args.scope)
return install(
selected, dest_root, force=args.force, dry_run=args.dry_run
)
if __name__ == "__main__":
raise SystemExit(main())