skillset 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skillset-0.1.0/PKG-INFO +5 -0
- skillset-0.1.0/README.md +65 -0
- skillset-0.1.0/pyproject.toml +27 -0
- skillset-0.1.0/setup.cfg +4 -0
- skillset-0.1.0/skillset/__init__.py +3 -0
- skillset-0.1.0/skillset/__main__.py +4 -0
- skillset-0.1.0/skillset/builtins.py +68 -0
- skillset-0.1.0/skillset/cli.py +451 -0
- skillset-0.1.0/skillset.egg-info/PKG-INFO +5 -0
- skillset-0.1.0/skillset.egg-info/SOURCES.txt +11 -0
- skillset-0.1.0/skillset.egg-info/dependency_links.txt +1 -0
- skillset-0.1.0/skillset.egg-info/entry_points.txt +2 -0
- skillset-0.1.0/skillset.egg-info/top_level.txt +1 -0
skillset-0.1.0/PKG-INFO
ADDED
skillset-0.1.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# skillset
|
|
2
|
+
|
|
3
|
+
Manage AI skills and permissions across projects for Claude Code.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install skillset
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with pip:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install skillset
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Apply permissions
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
skillset apply # auto-detect project type (git, python, node, etc.)
|
|
23
|
+
skillset apply python git # apply specific presets
|
|
24
|
+
skillset apply --dry-run # preview what would be applied
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Built-in presets: `developer`, `git`, `node`, `python`, `docker`, `k8s`
|
|
28
|
+
|
|
29
|
+
### Save and reuse permissions
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
skillset save mypreset # save current project permissions
|
|
33
|
+
skillset apply mypreset # apply in another project
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Add skills from GitHub
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
skillset add owner/repo # add to project .claude/skills/
|
|
40
|
+
skillset add owner/repo -g # add to global ~/.claude/skills/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### List installed skills and presets
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
skillset list
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Update cached repos
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
skillset update # pull all cached repos
|
|
53
|
+
skillset update owner/repo # update specific repo
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
- Permissions are written to `.claude/settings.local.json` (project-local, not committed)
|
|
59
|
+
- Skills are symlinked (Linux/Mac) or junctioned (Windows) from cached repos
|
|
60
|
+
- User presets stored in `~/.config/skillset/presets/`
|
|
61
|
+
- Repo cache in `~/.cache/skillset/repos/`
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skillset"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Manage AI skills and permissions across different projects"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
skillset = "skillset.cli:main"
|
|
10
|
+
|
|
11
|
+
[tool.uv]
|
|
12
|
+
package = true
|
|
13
|
+
|
|
14
|
+
[tool.ruff]
|
|
15
|
+
line-length = 100
|
|
16
|
+
target-version = "py311"
|
|
17
|
+
|
|
18
|
+
[tool.ruff.lint]
|
|
19
|
+
select = [
|
|
20
|
+
"E", # pycodestyle errors
|
|
21
|
+
"F", # pyflakes
|
|
22
|
+
"I", # isort
|
|
23
|
+
"UP", # pyupgrade
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.ruff.format]
|
|
27
|
+
quote-style = "double"
|
skillset-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Built-in permission presets."""
|
|
2
|
+
|
|
3
|
+
PRESETS: dict[str, dict] = {
|
|
4
|
+
"developer": {
|
|
5
|
+
"permissions": {
|
|
6
|
+
"allow": [
|
|
7
|
+
"Bash(git *)",
|
|
8
|
+
"Bash(npm *)",
|
|
9
|
+
"Bash(npx *)",
|
|
10
|
+
"Bash(yarn *)",
|
|
11
|
+
"Bash(pnpm *)",
|
|
12
|
+
"Bash(uv *)",
|
|
13
|
+
"Bash(pip *)",
|
|
14
|
+
"Bash(python *)",
|
|
15
|
+
"Bash(node *)",
|
|
16
|
+
"Bash(make *)",
|
|
17
|
+
"Bash(cargo *)",
|
|
18
|
+
"Bash(go *)",
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"git": {
|
|
23
|
+
"permissions": {
|
|
24
|
+
"allow": [
|
|
25
|
+
"Bash(git *)",
|
|
26
|
+
"Bash(gh *)",
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"node": {
|
|
31
|
+
"permissions": {
|
|
32
|
+
"allow": [
|
|
33
|
+
"Bash(npm *)",
|
|
34
|
+
"Bash(npx *)",
|
|
35
|
+
"Bash(yarn *)",
|
|
36
|
+
"Bash(pnpm *)",
|
|
37
|
+
"Bash(node *)",
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"python": {
|
|
42
|
+
"permissions": {
|
|
43
|
+
"allow": [
|
|
44
|
+
"Bash(uv *)",
|
|
45
|
+
"Bash(pip *)",
|
|
46
|
+
"Bash(python *)",
|
|
47
|
+
"Bash(pytest *)",
|
|
48
|
+
"Bash(ruff *)",
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"docker": {
|
|
53
|
+
"permissions": {
|
|
54
|
+
"allow": [
|
|
55
|
+
"Bash(docker *)",
|
|
56
|
+
"Bash(docker-compose *)",
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"k8s": {
|
|
61
|
+
"permissions": {
|
|
62
|
+
"allow": [
|
|
63
|
+
"Bash(kubectl *)",
|
|
64
|
+
"Bash(helm *)",
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""CLI for managing AI skills and permissions across projects."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from skillset.builtins import PRESETS as BUILTIN_PRESETS
|
|
11
|
+
|
|
12
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
13
|
+
CLAUDE_SETTINGS_FILE = ".claude/settings.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_presets_dir() -> Path:
|
|
17
|
+
"""Get the directory where permission presets are stored."""
|
|
18
|
+
return Path.home() / ".config" / "skillset" / "presets"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_cache_dir() -> Path:
|
|
22
|
+
"""Get the directory where repos are cached."""
|
|
23
|
+
return Path.home() / ".cache" / "skillset" / "repos"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_global_skills_dir() -> Path:
|
|
27
|
+
"""Get global Claude skills directory."""
|
|
28
|
+
return Path.home() / ".claude" / "skills"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_project_skills_dir() -> Path:
|
|
32
|
+
"""Get project-local Claude skills directory."""
|
|
33
|
+
return Path.cwd() / ".claude" / "skills"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_global_settings_path() -> Path:
|
|
37
|
+
"""Get global Claude settings.local path (user preferences)."""
|
|
38
|
+
return Path.home() / ".claude" / "settings.local.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_project_settings_path() -> Path:
|
|
42
|
+
"""Get project-local Claude settings.local path (user preferences)."""
|
|
43
|
+
return Path.cwd() / ".claude" / "settings.local.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_repo_spec(spec: str) -> tuple[str, str]:
|
|
47
|
+
"""Parse 'owner/repo' into (owner, repo)."""
|
|
48
|
+
parts = spec.strip().split("/")
|
|
49
|
+
if len(parts) != 2:
|
|
50
|
+
raise ValueError(f"Invalid repo format: {spec}. Use 'owner/repo'")
|
|
51
|
+
return parts[0], parts[1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_repo_dir(owner: str, repo: str) -> Path:
|
|
55
|
+
"""Get the cache directory for a repo."""
|
|
56
|
+
return get_cache_dir() / owner / repo
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clone_or_pull(owner: str, repo: str) -> Path:
|
|
60
|
+
"""Clone repo if not exists, or pull if it does. Returns repo path."""
|
|
61
|
+
repo_dir = get_repo_dir(owner, repo)
|
|
62
|
+
repo_url = f"https://github.com/{owner}/{repo}.git"
|
|
63
|
+
|
|
64
|
+
if repo_dir.exists():
|
|
65
|
+
print(f"Updating {owner}/{repo}...")
|
|
66
|
+
subprocess.run(["git", "pull"], cwd=repo_dir, check=True, capture_output=True)
|
|
67
|
+
else:
|
|
68
|
+
print(f"Cloning {owner}/{repo}...")
|
|
69
|
+
repo_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
subprocess.run(["git", "clone", repo_url, str(repo_dir)], check=True, capture_output=True)
|
|
71
|
+
|
|
72
|
+
return repo_dir
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_skills(repo_dir: Path) -> list[Path]:
|
|
76
|
+
"""Find skill directories in a repo. A skill is a dir with a markdown file."""
|
|
77
|
+
skills = []
|
|
78
|
+
for md_file in repo_dir.glob("**/*.md"):
|
|
79
|
+
if any(part.startswith(".") for part in md_file.relative_to(repo_dir).parts):
|
|
80
|
+
continue
|
|
81
|
+
if md_file.parent == repo_dir and md_file.name.lower() == "readme.md":
|
|
82
|
+
continue
|
|
83
|
+
skill_dir = md_file.parent
|
|
84
|
+
if skill_dir not in skills and skill_dir != repo_dir:
|
|
85
|
+
skills.append(skill_dir)
|
|
86
|
+
return skills
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_dir_link(link_path: Path, target_path: Path) -> None:
|
|
90
|
+
"""Create a directory link (junction on Windows, symlink on Unix)."""
|
|
91
|
+
if IS_WINDOWS:
|
|
92
|
+
# Use junction on Windows (no admin required)
|
|
93
|
+
subprocess.run(
|
|
94
|
+
["cmd", "/c", "mklink", "/J", str(link_path), str(target_path)],
|
|
95
|
+
check=True,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
link_path.symlink_to(target_path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_link(path: Path) -> bool:
|
|
103
|
+
"""Check if path is a symlink or junction."""
|
|
104
|
+
if IS_WINDOWS:
|
|
105
|
+
# Junctions appear as directories but have reparse points
|
|
106
|
+
return path.is_symlink() or (path.is_dir() and os.path.islink(str(path)))
|
|
107
|
+
return path.is_symlink()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def remove_link(path: Path) -> None:
|
|
111
|
+
"""Remove a symlink or junction."""
|
|
112
|
+
if IS_WINDOWS and path.is_dir():
|
|
113
|
+
# Junctions need rmdir, not unlink
|
|
114
|
+
os.rmdir(path)
|
|
115
|
+
else:
|
|
116
|
+
path.unlink()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def link_skills(repo_dir: Path, target_dir: Path) -> list[str]:
|
|
120
|
+
"""Link skill directories from repo to target skills dir."""
|
|
121
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
linked = []
|
|
123
|
+
for skill_dir in find_skills(repo_dir):
|
|
124
|
+
skill_name = skill_dir.name
|
|
125
|
+
link_path = target_dir / skill_name
|
|
126
|
+
if is_link(link_path):
|
|
127
|
+
remove_link(link_path)
|
|
128
|
+
elif link_path.exists():
|
|
129
|
+
print(f" Skipping {skill_name}: already exists (not a link)")
|
|
130
|
+
continue
|
|
131
|
+
create_dir_link(link_path, skill_dir)
|
|
132
|
+
linked.append(skill_name)
|
|
133
|
+
return linked
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_settings(settings_path: Path) -> dict:
|
|
137
|
+
"""Load Claude settings from a path."""
|
|
138
|
+
if settings_path.exists():
|
|
139
|
+
return json.loads(settings_path.read_text())
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_settings(settings_path: Path, settings: dict) -> None:
|
|
144
|
+
"""Save Claude settings to a path."""
|
|
145
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_repo_permissions(repo_dir: Path) -> dict | None:
|
|
150
|
+
"""Find and load permissions file from repo root."""
|
|
151
|
+
for name in ("settings.json", "permissions.json", "claude-settings.json"):
|
|
152
|
+
path = repo_dir / name
|
|
153
|
+
if path.exists():
|
|
154
|
+
return json.loads(path.read_text())
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def detect_project_types(project_dir: Path) -> list[str]:
|
|
159
|
+
"""Detect which built-in presets apply to a project."""
|
|
160
|
+
detected = []
|
|
161
|
+
if (project_dir / ".git").exists():
|
|
162
|
+
detected.append("git")
|
|
163
|
+
if (project_dir / "package.json").exists():
|
|
164
|
+
detected.append("node")
|
|
165
|
+
if any(
|
|
166
|
+
(project_dir / f).exists()
|
|
167
|
+
for f in ("pyproject.toml", "setup.py", "requirements.txt", "Pipfile")
|
|
168
|
+
):
|
|
169
|
+
detected.append("python")
|
|
170
|
+
if any(
|
|
171
|
+
(project_dir / f).exists()
|
|
172
|
+
for f in ("Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml")
|
|
173
|
+
):
|
|
174
|
+
detected.append("docker")
|
|
175
|
+
if any((project_dir / f).exists() for f in ("k8s", "kubernetes", "helm", "Chart.yaml")):
|
|
176
|
+
detected.append("k8s")
|
|
177
|
+
return detected
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
181
|
+
"""Deep merge two dictionaries, with override taking precedence."""
|
|
182
|
+
result = base.copy()
|
|
183
|
+
for key, value in override.items():
|
|
184
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
185
|
+
result[key] = deep_merge(result[key], value)
|
|
186
|
+
elif key in result and isinstance(result[key], list) and isinstance(value, list):
|
|
187
|
+
result[key] = list(set(result[key] + value))
|
|
188
|
+
else:
|
|
189
|
+
result[key] = value
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def merge_permissions(repo_dir: Path, settings_path: Path) -> list[str]:
|
|
194
|
+
"""Merge repo permissions into target settings."""
|
|
195
|
+
repo_perms = find_repo_permissions(repo_dir)
|
|
196
|
+
if not repo_perms:
|
|
197
|
+
return []
|
|
198
|
+
existing = load_settings(settings_path)
|
|
199
|
+
merged = deep_merge(existing, repo_perms)
|
|
200
|
+
save_settings(settings_path, merged)
|
|
201
|
+
return list(repo_perms.keys())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_user_preset(name: str) -> dict | None:
|
|
205
|
+
"""Load a user-saved preset by name."""
|
|
206
|
+
preset_path = get_presets_dir() / f"{name}.json"
|
|
207
|
+
if preset_path.exists():
|
|
208
|
+
return json.loads(preset_path.read_text())
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def save_user_preset(name: str, settings: dict) -> Path:
|
|
213
|
+
"""Save settings as a user preset."""
|
|
214
|
+
presets_dir = get_presets_dir()
|
|
215
|
+
presets_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
preset_path = presets_dir / f"{name}.json"
|
|
217
|
+
preset_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
218
|
+
return preset_path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_preset(name: str) -> dict | None:
|
|
222
|
+
"""Get a preset by name (checks user presets first, then builtins)."""
|
|
223
|
+
user_preset = load_user_preset(name)
|
|
224
|
+
if user_preset:
|
|
225
|
+
return user_preset
|
|
226
|
+
return BUILTIN_PRESETS.get(name)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Command handlers
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
233
|
+
"""List installed skills and saved presets."""
|
|
234
|
+
global_dir = get_global_skills_dir()
|
|
235
|
+
project_dir = get_project_skills_dir()
|
|
236
|
+
presets_dir = get_presets_dir()
|
|
237
|
+
|
|
238
|
+
global_skills = sorted(global_dir.iterdir()) if global_dir.exists() else []
|
|
239
|
+
project_skills = sorted(project_dir.iterdir()) if project_dir.exists() else []
|
|
240
|
+
saved_presets = sorted(presets_dir.glob("*.json")) if presets_dir.exists() else []
|
|
241
|
+
|
|
242
|
+
if global_skills:
|
|
243
|
+
print(f"Global skills ({global_dir}):")
|
|
244
|
+
for skill in global_skills:
|
|
245
|
+
suffix = " -> " + str(skill.resolve()) if is_link(skill) else ""
|
|
246
|
+
print(f" {skill.name}{suffix}")
|
|
247
|
+
|
|
248
|
+
if project_skills:
|
|
249
|
+
print(f"Project skills ({project_dir}):")
|
|
250
|
+
for skill in project_skills:
|
|
251
|
+
suffix = " -> " + str(skill.resolve()) if is_link(skill) else ""
|
|
252
|
+
print(f" {skill.name}{suffix}")
|
|
253
|
+
|
|
254
|
+
if saved_presets:
|
|
255
|
+
print(f"Saved presets ({presets_dir}):")
|
|
256
|
+
for preset in saved_presets:
|
|
257
|
+
print(f" {preset.stem}")
|
|
258
|
+
|
|
259
|
+
if not global_skills and not project_skills and not saved_presets:
|
|
260
|
+
print("No skills or presets found")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_save(args: argparse.Namespace) -> None:
|
|
264
|
+
"""Save current project permissions as a reusable preset."""
|
|
265
|
+
settings_path = get_project_settings_path()
|
|
266
|
+
if not settings_path.exists():
|
|
267
|
+
print(f"No settings found at {settings_path}")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
settings = load_settings(settings_path)
|
|
271
|
+
preset_path = save_user_preset(args.name, settings)
|
|
272
|
+
print(f"Saved preset '{args.name}' to {preset_path}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_apply(args: argparse.Namespace) -> None:
|
|
276
|
+
"""Apply permission presets (auto-detect or specific)."""
|
|
277
|
+
settings_path = get_project_settings_path()
|
|
278
|
+
|
|
279
|
+
# Specific preset(s) given
|
|
280
|
+
if args.presets:
|
|
281
|
+
existing = load_settings(settings_path)
|
|
282
|
+
total_perms = 0
|
|
283
|
+
applied = []
|
|
284
|
+
for name in args.presets:
|
|
285
|
+
preset = get_preset(name)
|
|
286
|
+
if not preset:
|
|
287
|
+
print(f"Unknown preset '{name}'")
|
|
288
|
+
sys.exit(1)
|
|
289
|
+
existing = deep_merge(existing, preset)
|
|
290
|
+
total_perms += len(preset.get("permissions", {}).get("allow", []))
|
|
291
|
+
applied.append(name)
|
|
292
|
+
save_settings(settings_path, existing)
|
|
293
|
+
print(f"Applied {', '.join(applied)} ({total_perms} permissions) to {settings_path}")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Auto-detect
|
|
297
|
+
project_dir = Path.cwd()
|
|
298
|
+
detected = detect_project_types(project_dir)
|
|
299
|
+
|
|
300
|
+
if not detected:
|
|
301
|
+
print("No project types detected. Use 'skillset apply <preset> -p' to apply manually.")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
print(f"Detected: {', '.join(detected)}")
|
|
305
|
+
|
|
306
|
+
if args.dry_run:
|
|
307
|
+
print("Would apply these presets (dry-run):")
|
|
308
|
+
for name in detected:
|
|
309
|
+
if name in BUILTIN_PRESETS:
|
|
310
|
+
perms = BUILTIN_PRESETS[name].get("permissions", {}).get("allow", [])
|
|
311
|
+
print(f" {name}: {len(perms)} permission(s)")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
existing = load_settings(settings_path)
|
|
315
|
+
total_perms = 0
|
|
316
|
+
for name in detected:
|
|
317
|
+
if name in BUILTIN_PRESETS:
|
|
318
|
+
preset = BUILTIN_PRESETS[name]
|
|
319
|
+
existing = deep_merge(existing, preset)
|
|
320
|
+
total_perms += len(preset.get("permissions", {}).get("allow", []))
|
|
321
|
+
|
|
322
|
+
save_settings(settings_path, existing)
|
|
323
|
+
print(f"Applied {len(detected)} preset(s) ({total_perms} permissions) to {settings_path}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_add(args: argparse.Namespace) -> None:
|
|
327
|
+
"""Add skills and permissions from a GitHub repo."""
|
|
328
|
+
try:
|
|
329
|
+
owner, repo_name = parse_repo_spec(args.repo)
|
|
330
|
+
except ValueError as e:
|
|
331
|
+
print(str(e))
|
|
332
|
+
sys.exit(1)
|
|
333
|
+
|
|
334
|
+
repo_dir = clone_or_pull(owner, repo_name)
|
|
335
|
+
|
|
336
|
+
# Link skills (global or project)
|
|
337
|
+
skills_dir = get_global_skills_dir() if args.g else get_project_skills_dir()
|
|
338
|
+
linked = link_skills(repo_dir, skills_dir)
|
|
339
|
+
|
|
340
|
+
if linked:
|
|
341
|
+
print(f"Linked {len(linked)} skill(s) to {skills_dir}:")
|
|
342
|
+
for skill_name in sorted(linked):
|
|
343
|
+
print(f" - {skill_name}")
|
|
344
|
+
|
|
345
|
+
# Merge permissions (always project)
|
|
346
|
+
settings_path = get_project_settings_path()
|
|
347
|
+
merged_keys = merge_permissions(repo_dir, settings_path)
|
|
348
|
+
|
|
349
|
+
if merged_keys:
|
|
350
|
+
print(f"Merged permissions into {settings_path}:")
|
|
351
|
+
for key in sorted(merged_keys):
|
|
352
|
+
print(f" - {key}")
|
|
353
|
+
|
|
354
|
+
if not linked and not merged_keys:
|
|
355
|
+
print("No skills or permissions found in repo")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_update(args: argparse.Namespace) -> None:
|
|
359
|
+
"""Update repo(s) and refresh symlinks and permissions."""
|
|
360
|
+
cache_dir = get_cache_dir()
|
|
361
|
+
|
|
362
|
+
if args.repo:
|
|
363
|
+
try:
|
|
364
|
+
owner, repo_name = parse_repo_spec(args.repo)
|
|
365
|
+
except ValueError as e:
|
|
366
|
+
print(str(e))
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
repo_dir = get_repo_dir(owner, repo_name)
|
|
370
|
+
if not repo_dir.exists():
|
|
371
|
+
print(f"Repo {args.repo} not installed. Use 'skillset add {args.repo}' first.")
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
clone_or_pull(owner, repo_name)
|
|
375
|
+
|
|
376
|
+
# Refresh skills (global or project)
|
|
377
|
+
skills_dir = get_global_skills_dir() if args.g else get_project_skills_dir()
|
|
378
|
+
linked = link_skills(repo_dir, skills_dir)
|
|
379
|
+
print(f"Updated {len(linked)} skill(s)")
|
|
380
|
+
|
|
381
|
+
# Refresh permissions (always project)
|
|
382
|
+
settings_path = get_project_settings_path()
|
|
383
|
+
merged_keys = merge_permissions(repo_dir, settings_path)
|
|
384
|
+
if merged_keys:
|
|
385
|
+
print(f"Refreshed {len(merged_keys)} permission key(s)")
|
|
386
|
+
else:
|
|
387
|
+
if not cache_dir.exists():
|
|
388
|
+
print("No repos installed")
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
for owner_dir in cache_dir.iterdir():
|
|
392
|
+
if not owner_dir.is_dir():
|
|
393
|
+
continue
|
|
394
|
+
for repo_dir in owner_dir.iterdir():
|
|
395
|
+
if not repo_dir.is_dir():
|
|
396
|
+
continue
|
|
397
|
+
clone_or_pull(owner_dir.name, repo_dir.name)
|
|
398
|
+
print("All repos updated")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def main() -> None:
|
|
402
|
+
parser = argparse.ArgumentParser(
|
|
403
|
+
prog="skillset",
|
|
404
|
+
description="Manage AI skills and permissions across projects",
|
|
405
|
+
)
|
|
406
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
407
|
+
|
|
408
|
+
# list
|
|
409
|
+
subparsers.add_parser("list", help="list installed skills")
|
|
410
|
+
|
|
411
|
+
# save
|
|
412
|
+
p_save = subparsers.add_parser("save", help="save project permissions as reusable preset")
|
|
413
|
+
p_save.add_argument("name", help="preset name")
|
|
414
|
+
|
|
415
|
+
# apply
|
|
416
|
+
p_apply = subparsers.add_parser(
|
|
417
|
+
"apply", help="apply permission presets (auto-detect or specific)"
|
|
418
|
+
)
|
|
419
|
+
p_apply.add_argument(
|
|
420
|
+
"presets", nargs="*", help="preset name(s) to apply (auto-detect if omitted)"
|
|
421
|
+
)
|
|
422
|
+
p_apply.add_argument("--dry-run", action="store_true", help="show what would be applied")
|
|
423
|
+
|
|
424
|
+
# add
|
|
425
|
+
p_add = subparsers.add_parser("add", help="add skills from a GitHub repo")
|
|
426
|
+
p_add.add_argument("repo", help="repo in owner/repo format")
|
|
427
|
+
p_add.add_argument(
|
|
428
|
+
"-g", "--global", dest="g", action="store_true", help="install skills globally"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# update
|
|
432
|
+
p_update = subparsers.add_parser("update", help="update repo(s) and refresh links")
|
|
433
|
+
p_update.add_argument("repo", nargs="?", help="specific repo to update (optional)")
|
|
434
|
+
p_update.add_argument(
|
|
435
|
+
"-g", "--global", dest="g", action="store_true", help="update global skills"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
args = parser.parse_args()
|
|
439
|
+
|
|
440
|
+
handlers = {
|
|
441
|
+
"list": cmd_list,
|
|
442
|
+
"save": cmd_save,
|
|
443
|
+
"apply": cmd_apply,
|
|
444
|
+
"add": cmd_add,
|
|
445
|
+
"update": cmd_update,
|
|
446
|
+
}
|
|
447
|
+
handlers[args.command](args)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
skillset/__init__.py
|
|
4
|
+
skillset/__main__.py
|
|
5
|
+
skillset/builtins.py
|
|
6
|
+
skillset/cli.py
|
|
7
|
+
skillset.egg-info/PKG-INFO
|
|
8
|
+
skillset.egg-info/SOURCES.txt
|
|
9
|
+
skillset.egg-info/dependency_links.txt
|
|
10
|
+
skillset.egg-info/entry_points.txt
|
|
11
|
+
skillset.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
skillset
|