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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillset
3
+ Version: 0.1.0
4
+ Summary: Manage AI skills and permissions across different projects
5
+ Requires-Python: >=3.11
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Skillset - Manage AI skills and permissions across projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from skillset.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillset
3
+ Version: 0.1.0
4
+ Summary: Manage AI skills and permissions across different projects
5
+ Requires-Python: >=3.11
@@ -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,2 @@
1
+ [console_scripts]
2
+ skillset = skillset.cli:main
@@ -0,0 +1 @@
1
+ skillset