skm-cli 0.2.1__tar.gz → 0.2.2__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.
Files changed (51) hide show
  1. {skm_cli-0.2.1 → skm_cli-0.2.2}/AGENTS.md +3 -0
  2. {skm_cli-0.2.1 → skm_cli-0.2.2}/PKG-INFO +16 -1
  3. {skm_cli-0.2.1 → skm_cli-0.2.2}/README.md +15 -0
  4. {skm_cli-0.2.1 → skm_cli-0.2.2}/pyproject.toml +1 -1
  5. skm_cli-0.2.2/skills.example.yaml +76 -0
  6. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/install.py +36 -5
  7. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/update.py +15 -11
  8. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/detect.py +13 -1
  9. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/git.py +6 -2
  10. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/types.py +22 -1
  11. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_cli_e2e.py +111 -0
  12. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_config.py +2 -0
  13. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_detect.py +30 -0
  14. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_git.py +48 -0
  15. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_install.py +67 -0
  16. skm_cli-0.2.2/tests/test_types.py +83 -0
  17. {skm_cli-0.2.1 → skm_cli-0.2.2}/uv.lock +1 -1
  18. skm_cli-0.2.1/skills.example.yaml +0 -38
  19. skm_cli-0.2.1/tests/test_types.py +0 -39
  20. {skm_cli-0.2.1 → skm_cli-0.2.2}/.gitignore +0 -0
  21. {skm_cli-0.2.1 → skm_cli-0.2.2}/CLAUDE.md +0 -0
  22. {skm_cli-0.2.1 → skm_cli-0.2.2}/draft.md +0 -0
  23. {skm_cli-0.2.1 → skm_cli-0.2.2}/images/skm-install.png +0 -0
  24. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
  25. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
  26. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
  27. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
  28. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
  29. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
  30. {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
  31. {skm_cli-0.2.1 → skm_cli-0.2.2}/link-skill +0 -0
  32. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/__init__.py +0 -0
  33. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/cli.py +0 -0
  34. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/clonefile.py +0 -0
  35. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/__init__.py +0 -0
  36. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/check_updates.py +0 -0
  37. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/list_cmd.py +0 -0
  38. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/remove.py +0 -0
  39. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/view.py +0 -0
  40. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/config.py +0 -0
  41. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/linker.py +0 -0
  42. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/lock.py +0 -0
  43. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/py.typed +0 -0
  44. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/tui.py +0 -0
  45. {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/utils.py +0 -0
  46. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_clonefile.py +0 -0
  47. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_install_from_source.py +0 -0
  48. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_linker.py +0 -0
  49. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_list_cmd.py +0 -0
  50. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_local_path.py +0 -0
  51. {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_lock.py +0 -0
@@ -81,6 +81,9 @@ packages:
81
81
  agents: # optional: further filter agents for this package
82
82
  excludes:
83
83
  - standard
84
+ - local_path: ~/Code/devblocks
85
+ skills_excludes: # optional: install all skills EXCEPT these (mutually exclusive with skills)
86
+ - team-squad
84
87
  - repo: https://github.com/blader/humanizer # installs all detected skills to default agents
85
88
  ```
86
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skm-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Skill Manager - manage agent skills from GitHub repos
5
5
  Author-email: Reorx <novoreorx@gmail.com>
6
6
  Requires-Python: >=3.12
@@ -96,6 +96,8 @@ agents:
96
96
 
97
97
  packages:
98
98
  - repo: https://github.com/vercel-labs/agent-skills
99
+ clone_strategy: shallow # optional: use git clone --filter=blob:none --depth 1
100
+ skills_dir: skills/.curated # optional: search only this repo-relative directory
99
101
  skills: # optional: install only these skills (omit = all)
100
102
  - vercel-react-best-practices
101
103
  agents: # optional: further filter agents for this package
@@ -107,10 +109,21 @@ packages:
107
109
  - local_path: ~/Code/my-custom-skills # use a local directory as package source
108
110
  skills:
109
111
  - my-skill
112
+
113
+ - local_path: ~/Code/devblocks
114
+ skills_excludes: # optional: install all skills EXCEPT these (mutually exclusive with skills)
115
+ - team-squad
116
+ - team-council
110
117
  ```
111
118
 
112
119
  Each package must specify exactly one of `repo` or `local_path`. Local path packages use the directory directly (no cloning) and are skipped by `check-updates` and `update`.
113
120
 
121
+ `clone_strategy: shallow` is optional for repo packages. When set, the first clone uses `git clone --filter=blob:none --depth 1`; omitted or other values keep the default clone command, `git clone --filter=blob:none`. Existing cached repos are reused and pulled, so changing this option does not re-clone an already-present repo.
122
+
123
+ `skills_dir` is optional and must be a relative path inside the package. When set, skm searches only that directory for `SKILL.md` files instead of using the default detection order.
124
+
125
+ `skills` and `skills_excludes` are mutually exclusive. `skills` installs only the listed skills; `skills_excludes` installs all detected skills except the listed ones. Omitting both installs all detected skills.
126
+
114
127
  ## Install Sync Behavior
115
128
 
116
129
  `skm install` treats `skills.yaml` as a declarative state file. Each run syncs the agent directories to match the config:
@@ -130,6 +143,8 @@ A skill is a directory containing a `SKILL.md` file with YAML frontmatter (`name
130
143
  3. Otherwise — walk all subdirectories from repo root
131
144
  4. Stop descending once `SKILL.md` is found (no nested skills)
132
145
 
146
+ If a package sets `skills_dir`, this default order is bypassed and only that directory is scanned. If `skills_dir` itself contains `SKILL.md`, it is treated as a singleton skill.
147
+
133
148
  ## Known Agents
134
149
 
135
150
  Skills are symlinked into these directories by default:
@@ -85,6 +85,8 @@ agents:
85
85
 
86
86
  packages:
87
87
  - repo: https://github.com/vercel-labs/agent-skills
88
+ clone_strategy: shallow # optional: use git clone --filter=blob:none --depth 1
89
+ skills_dir: skills/.curated # optional: search only this repo-relative directory
88
90
  skills: # optional: install only these skills (omit = all)
89
91
  - vercel-react-best-practices
90
92
  agents: # optional: further filter agents for this package
@@ -96,10 +98,21 @@ packages:
96
98
  - local_path: ~/Code/my-custom-skills # use a local directory as package source
97
99
  skills:
98
100
  - my-skill
101
+
102
+ - local_path: ~/Code/devblocks
103
+ skills_excludes: # optional: install all skills EXCEPT these (mutually exclusive with skills)
104
+ - team-squad
105
+ - team-council
99
106
  ```
100
107
 
101
108
  Each package must specify exactly one of `repo` or `local_path`. Local path packages use the directory directly (no cloning) and are skipped by `check-updates` and `update`.
102
109
 
110
+ `clone_strategy: shallow` is optional for repo packages. When set, the first clone uses `git clone --filter=blob:none --depth 1`; omitted or other values keep the default clone command, `git clone --filter=blob:none`. Existing cached repos are reused and pulled, so changing this option does not re-clone an already-present repo.
111
+
112
+ `skills_dir` is optional and must be a relative path inside the package. When set, skm searches only that directory for `SKILL.md` files instead of using the default detection order.
113
+
114
+ `skills` and `skills_excludes` are mutually exclusive. `skills` installs only the listed skills; `skills_excludes` installs all detected skills except the listed ones. Omitting both installs all detected skills.
115
+
103
116
  ## Install Sync Behavior
104
117
 
105
118
  `skm install` treats `skills.yaml` as a declarative state file. Each run syncs the agent directories to match the config:
@@ -119,6 +132,8 @@ A skill is a directory containing a `SKILL.md` file with YAML frontmatter (`name
119
132
  3. Otherwise — walk all subdirectories from repo root
120
133
  4. Stop descending once `SKILL.md` is found (no nested skills)
121
134
 
135
+ If a package sets `skills_dir`, this default order is bypassed and only that directory is scanned. If `skills_dir` itself contains `SKILL.md`, it is treated as a singleton skill.
136
+
122
137
  ## Known Agents
123
138
 
124
139
  Skills are symlinked into these directories by default:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skm-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Skill Manager - manage agent skills from GitHub repos"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,76 @@
1
+ # skills.example.yaml — copy to ~/.config/skm/skills.yaml and edit.
2
+ #
3
+ # skm treats this file as declarative state: it clones each package, detects
4
+ # skills (directories containing a SKILL.md), and symlinks them into your agent
5
+ # skill dirs. Skills you remove from here are unlinked on the next `skm install`.
6
+
7
+ # Which agents from KNOWN_AGENTS receive skills by default.
8
+ # Omit this whole block to target every known agent.
9
+ # Known agents: standard, claude, codex, openclaw, pi
10
+ agents:
11
+ default:
12
+ - claude
13
+ - codex
14
+ - standard
15
+
16
+ packages:
17
+ # --- Local package --------------------------------------------------------
18
+ # Point at a directory on disk instead of a git repo — ideal for skills you
19
+ # author yourself. It's symlinked in place, so your edits apply immediately.
20
+ - local_path: ~/Code/my-skills
21
+
22
+ # --- Whole repo -----------------------------------------------------------
23
+ # No `skills` filter → every skill detected in the repo is installed.
24
+ - repo: https://github.com/blader/humanizer
25
+
26
+ # --- Pick specific skills -------------------------------------------------
27
+ # A repo often ships many skills; list only the ones you want.
28
+ - repo: https://github.com/vercel-labs/agent-skills
29
+ skills:
30
+ - vercel-react-best-practices
31
+ - web-design-guidelines
32
+
33
+ # --- Per-package agent filtering ------------------------------------------
34
+ # `includes` narrows this package to a subset of the default agents.
35
+ # Use `excludes` for the inverse. (The two are mutually exclusive.)
36
+ - repo: https://github.com/vercel-labs/agent-browser
37
+ skills:
38
+ - agent-browser # drive a real browser: navigate, fill forms, screenshot
39
+ - dogfood # exploratory-test a web app and report bugs
40
+ - electron # automate Electron desktop apps (VS Code, Slack, ...)
41
+ agents:
42
+ includes:
43
+ - claude
44
+ - codex
45
+
46
+ # --- Shallow clone --------------------------------------------------------
47
+ # For big repos where history doesn't matter: clone with --depth 1.
48
+ - repo: https://github.com/getpaseo/paseo
49
+ clone_strategy: shallow
50
+
51
+ # --- Custom skills directory ----------------------------------------------
52
+ # When a repo keeps skills somewhere other than its root or ./skills/,
53
+ # point skm at the right subdirectory with `skills_dir`.
54
+ - repo: https://github.com/pbakaus/impeccable
55
+ skills_dir: source
56
+
57
+ # --- A grab bag of useful real-world skills -------------------------------
58
+ - repo: https://github.com/firecrawl/cli
59
+ skills:
60
+ - firecrawl-scrape # extract clean markdown from any URL (JS-rendered too)
61
+ - firecrawl-search # web search returning full-page content, not snippets
62
+ - repo: http://github.com/better-auth/skills
63
+ skills:
64
+ - better-auth-best-practices
65
+ - create-auth-skill
66
+ - repo: https://github.com/PsiACE/skills
67
+ skills:
68
+ - friendly-python # write Pythonic, readable code
69
+ - piglet # deeper Python craftsmanship review
70
+ - repo: https://github.com/cloudflare/skills
71
+ skills:
72
+ - cloudflare # Workers, KV, D1, R2, AI, and more
73
+ - wrangler # the Cloudflare Workers CLI
74
+ - repo: https://github.com/getsentry/skills
75
+ skills:
76
+ - django-perf-review # find N+1 queries and ORM bottlenecks
@@ -179,7 +179,10 @@ def _install_local(repo_config, new_lock_skills, configured_skill_keys, known_ag
179
179
  if verbose:
180
180
  click.echo(click.style(f'Using local path {source_label}', fg='blue', bold=True))
181
181
 
182
- detected = detect_skills(local_path)
182
+ try:
183
+ detected = detect_skills(local_path, repo_config.skills_dir)
184
+ except ValueError as e:
185
+ raise click.ClickException(str(e))
183
186
  if verbose:
184
187
  click.echo(click.style(f' Found skills: {", ".join(s.name for s in detected) or "(none)"}', dim=True))
185
188
 
@@ -191,6 +194,9 @@ def _install_local(repo_config, new_lock_skills, configured_skill_keys, known_ag
191
194
  missing = requested - {s.name for s in skills_to_install}
192
195
  if missing:
193
196
  click.echo(click.style(f' Warning: skills not found: {missing}', fg='red'))
197
+ elif repo_config.skills_excludes is not None:
198
+ excluded = set(repo_config.skills_excludes)
199
+ skills_to_install = [s for s in detected if s.name not in excluded]
194
200
  else:
195
201
  skills_to_install = detected
196
202
 
@@ -259,7 +265,8 @@ def _install_repo(
259
265
  repo_dir_name = repo_url_to_dirname(repo_config.repo)
260
266
  repo_path = store_dir / repo_dir_name
261
267
 
262
- if repo_path.exists() and (repo_path / '.git').exists():
268
+ was_existing = repo_path.exists() and (repo_path / '.git').exists()
269
+ if was_existing:
263
270
  if verbose:
264
271
  click.echo(click.style(f'Using existing {repo_config.repo}', fg='blue', bold=True))
265
272
  else:
@@ -267,10 +274,13 @@ def _install_repo(
267
274
  _progress(f' Cloning {repo_config.repo}...')
268
275
  else:
269
276
  click.echo(click.style(f'Cloning {repo_config.repo}...', fg='blue', bold=True))
270
- clone_or_pull(repo_config.repo, repo_path)
277
+ clone_or_pull(repo_config.repo, repo_path, clone_strategy=repo_config.clone_strategy)
271
278
 
272
279
  commit = get_head_commit(repo_path)
273
- detected = detect_skills(repo_path)
280
+ try:
281
+ detected = detect_skills(repo_path, repo_config.skills_dir)
282
+ except ValueError as e:
283
+ raise click.ClickException(str(e))
274
284
  if verbose:
275
285
  click.echo(click.style(f' Found skills: {", ".join(s.name for s in detected) or "(none)"}', dim=True))
276
286
 
@@ -280,8 +290,29 @@ def _install_repo(
280
290
  requested = set(repo_config.skills)
281
291
  skills_to_install = [s for s in detected if s.name in requested]
282
292
  missing = requested - {s.name for s in skills_to_install}
283
- if missing:
293
+ if missing and was_existing:
294
+ # Repo was already cloned but requested skills are missing — pull and retry
295
+ if not verbose:
296
+ _progress(f' Pulling {repo_config.repo} (missing skills: {", ".join(sorted(missing))})...')
297
+ else:
298
+ click.echo(click.style(f' Pulling {repo_config.repo} (missing skills: {", ".join(sorted(missing))})...', fg='blue'))
299
+ clone_or_pull(repo_config.repo, repo_path, clone_strategy=repo_config.clone_strategy)
300
+ commit = get_head_commit(repo_path)
301
+ try:
302
+ detected = detect_skills(repo_path, repo_config.skills_dir)
303
+ except ValueError as e:
304
+ raise click.ClickException(str(e))
305
+ if verbose:
306
+ click.echo(click.style(f' Found skills after pull: {", ".join(s.name for s in detected) or "(none)"}', dim=True))
307
+ skills_to_install = [s for s in detected if s.name in requested]
308
+ still_missing = requested - {s.name for s in skills_to_install}
309
+ if still_missing:
310
+ click.echo(click.style(f' Warning: skills not found in repo: {still_missing}', fg='red'))
311
+ elif missing:
284
312
  click.echo(click.style(f' Warning: skills not found in repo: {missing}', fg='red'))
313
+ elif repo_config.skills_excludes is not None:
314
+ excluded = set(repo_config.skills_excludes)
315
+ skills_to_install = [s for s in detected if s.name not in excluded]
285
316
  else:
286
317
  skills_to_install = detected
287
318
 
@@ -66,6 +66,16 @@ def _update_repo(
66
66
  repo_dir_name = repo_url_to_dirname(repo_url)
67
67
  repo_path = store_dir / repo_dir_name
68
68
 
69
+ repo_config = None
70
+ for c in config.packages:
71
+ if c.repo == repo_url:
72
+ repo_config = c
73
+ break
74
+
75
+ if repo_config is None:
76
+ click.echo(f" Error: repo '{repo_url}' not found in config. Cannot determine target agents.")
77
+ raise SystemExit(1)
78
+
69
79
  # Get old commit from any skill in this repo
70
80
  old_commit = None
71
81
  for s in lock.skills:
@@ -75,7 +85,7 @@ def _update_repo(
75
85
 
76
86
  # Pull latest
77
87
  click.echo(f'Pulling {click.style(repo_url, fg="cyan")}...')
78
- clone_or_pull(repo_url, repo_path)
88
+ clone_or_pull(repo_url, repo_path, clone_strategy=repo_config.clone_strategy)
79
89
  new_commit = get_head_commit(repo_path)
80
90
 
81
91
  if old_commit == new_commit:
@@ -90,16 +100,10 @@ def _update_repo(
90
100
  click.echo(click.style(f' {line}', dim=True))
91
101
 
92
102
  # Re-detect and re-link
93
- detected = detect_skills(repo_path)
94
- repo_config = None
95
- for c in config.packages:
96
- if c.repo == repo_url:
97
- repo_config = c
98
- break
99
-
100
- if repo_config is None:
101
- click.echo(f" Error: repo '{repo_url}' not found in config. Cannot determine target agents.")
102
- raise SystemExit(1)
103
+ try:
104
+ detected = detect_skills(repo_path, repo_config.skills_dir)
105
+ except ValueError as e:
106
+ raise click.ClickException(str(e))
103
107
 
104
108
  target_agents = resolve_target_agents(
105
109
  repo_config.agents,
@@ -16,8 +16,20 @@ def parse_skill_name(skill_md_path: Path) -> str:
16
16
  raise ValueError(f"No 'name' field in frontmatter of {skill_md_path}")
17
17
 
18
18
 
19
- def detect_skills(repo_path: Path) -> list[DetectedSkill]:
19
+ def detect_skills(repo_path: Path, skills_dir: str | None = None) -> list[DetectedSkill]:
20
20
  """Detect skills in a cloned repo by walking for SKILL.md files."""
21
+ if skills_dir is not None:
22
+ walk_root = repo_path / skills_dir
23
+ if not walk_root.is_dir():
24
+ raise ValueError(f"skills_dir does not exist or is not a directory: {skills_dir}")
25
+
26
+ root_skill = walk_root / 'SKILL.md'
27
+ if root_skill.exists():
28
+ name = parse_skill_name(root_skill)
29
+ return [DetectedSkill(name=name, path=walk_root, relative_path=str(walk_root.relative_to(repo_path)))]
30
+
31
+ return _walk_for_skills(walk_root, repo_path)
32
+
21
33
  # Case 1: Root has SKILL.md → singleton skill
22
34
  root_skill = repo_path / 'SKILL.md'
23
35
  if root_skill.exists():
@@ -44,14 +44,18 @@ def _validate_sha(sha: str) -> None:
44
44
  raise ValueError(f"Invalid commit SHA: {sha!r}")
45
45
 
46
46
 
47
- def clone_or_pull(repo_url: str, dest: Path) -> None:
47
+ def clone_or_pull(repo_url: str, dest: Path, clone_strategy: str | None = None) -> None:
48
48
  """Clone repo if not present, otherwise pull latest."""
49
49
  if dest.exists() and (dest / ".git").exists():
50
50
  run_cmd(["git", "pull", "--ff-only"], cwd=dest)
51
51
  else:
52
52
  _validate_repo_url(repo_url)
53
53
  dest.parent.mkdir(parents=True, exist_ok=True)
54
- run_cmd(["git", "clone", "--filter=blob:none", repo_url, str(dest)])
54
+ cmd = ["git", "clone", "--filter=blob:none"]
55
+ if clone_strategy == "shallow":
56
+ cmd.extend(["--depth", "1"])
57
+ cmd.extend([repo_url, str(dest)])
58
+ run_cmd(cmd)
55
59
 
56
60
 
57
61
  def get_head_commit(repo_path: Path) -> str:
@@ -1,5 +1,5 @@
1
1
  import os
2
- from pathlib import Path
2
+ from pathlib import Path, PurePosixPath
3
3
  from pydantic import BaseModel, field_validator, model_validator
4
4
 
5
5
 
@@ -20,7 +20,10 @@ class AgentsConfig(BaseModel):
20
20
  class SkillRepoConfig(BaseModel):
21
21
  repo: str | None = None
22
22
  local_path: str | None = None
23
+ clone_strategy: str | None = None
24
+ skills_dir: str | None = None
23
25
  skills: list[str] | None = None
26
+ skills_excludes: list[str] | None = None
24
27
  agents: AgentsConfig | None = None
25
28
 
26
29
  @model_validator(mode='after')
@@ -31,6 +34,24 @@ class SkillRepoConfig(BaseModel):
31
34
  raise ValueError("Must specify exactly one of 'repo' or 'local_path'")
32
35
  return self
33
36
 
37
+ @model_validator(mode='after')
38
+ def check_skills_mutual_exclusion(self):
39
+ if self.skills is not None and self.skills_excludes is not None:
40
+ raise ValueError("Cannot specify both 'skills' and 'skills_excludes' in a package")
41
+ return self
42
+
43
+ @field_validator('skills_dir')
44
+ @classmethod
45
+ def check_skills_dir(cls, v: str | None) -> str | None:
46
+ if v is None:
47
+ return v
48
+ if not v.strip():
49
+ raise ValueError("'skills_dir' cannot be empty")
50
+ path = PurePosixPath(v)
51
+ if path.is_absolute() or '..' in path.parts:
52
+ raise ValueError("'skills_dir' must be a relative path within the package")
53
+ return v.rstrip('/')
54
+
34
55
  @property
35
56
  def is_local(self) -> bool:
36
57
  return self.local_path is not None
@@ -170,6 +170,63 @@ class TestInstall:
170
170
  assert len(lock['skills']) == 1
171
171
  assert lock['skills'][0]['name'] == 'wanted'
172
172
 
173
+ def test_install_with_skills_excludes(self, tmp_path):
174
+ repo = _make_skill_repo(
175
+ tmp_path,
176
+ 'repo-skill-excl',
177
+ [
178
+ {'name': 'keep-a'},
179
+ {'name': 'keep-b'},
180
+ {'name': 'dropped'},
181
+ ],
182
+ )
183
+ _write_config(tmp_path, [{'repo': str(repo), 'skills_excludes': ['dropped']}])
184
+
185
+ runner = CliRunner()
186
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
187
+
188
+ assert result.exit_code == 0, result.output
189
+ lock = _load_lock(tmp_path)
190
+ installed = sorted(s['name'] for s in lock['skills'])
191
+ assert installed == ['keep-a', 'keep-b']
192
+ assert (tmp_path / 'agents' / 'claude' / 'keep-a').is_symlink()
193
+ assert (tmp_path / 'agents' / 'claude' / 'keep-b').is_symlink()
194
+ assert not (tmp_path / 'agents' / 'claude' / 'dropped').exists()
195
+
196
+ def test_install_with_skills_excludes_local_path(self, tmp_path):
197
+ repo = _make_skill_repo(
198
+ tmp_path,
199
+ 'local-skill-excl',
200
+ [
201
+ {'name': 'keep-me'},
202
+ {'name': 'drop-me'},
203
+ ],
204
+ )
205
+ _write_config(tmp_path, [{'local_path': str(repo), 'skills_excludes': ['drop-me']}])
206
+
207
+ runner = CliRunner()
208
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
209
+
210
+ assert result.exit_code == 0, result.output
211
+ lock = _load_lock(tmp_path)
212
+ installed = sorted(s['name'] for s in lock['skills'])
213
+ assert installed == ['keep-me']
214
+ assert (tmp_path / 'agents' / 'claude' / 'keep-me').is_symlink()
215
+ assert not (tmp_path / 'agents' / 'claude' / 'drop-me').exists()
216
+
217
+ def test_install_rejects_skills_and_skills_excludes(self, tmp_path):
218
+ repo = _make_skill_repo(tmp_path, 'repo-conflict', [{'name': 'a-skill'}])
219
+ _write_config(
220
+ tmp_path,
221
+ [{'repo': str(repo), 'skills': ['a-skill'], 'skills_excludes': ['other']}],
222
+ )
223
+
224
+ runner = CliRunner()
225
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
226
+
227
+ assert result.exit_code != 0
228
+ assert 'skills_excludes' in str(result.exception)
229
+
173
230
  def test_install_with_agents_excludes(self, tmp_path):
174
231
  repo = _make_skill_repo(tmp_path, 'repo-excl', [{'name': 'my-skill'}])
175
232
  _write_config(
@@ -264,6 +321,40 @@ class TestInstall:
264
321
  dup_skills = [s for s in lock['skills'] if s['name'] == 'dup-skill']
265
322
  assert len(dup_skills) == 1
266
323
 
324
+ def test_install_pulls_when_requested_skills_missing(self, tmp_path):
325
+ """When requested skills aren't found in an existing repo, pull and retry."""
326
+ # Create repo with only one skill initially
327
+ repo = _make_skill_repo(tmp_path, 'repo-pull', [{'name': 'old-skill'}])
328
+
329
+ # First install: only request old-skill
330
+ _write_config(tmp_path, [{'repo': str(repo), 'skills': ['old-skill']}])
331
+ runner = CliRunner()
332
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
333
+ assert result.exit_code == 0, result.output
334
+ assert (tmp_path / 'agents' / 'claude' / 'old-skill').is_symlink()
335
+
336
+ # Add a new skill to the source repo
337
+ new_skill_dir = repo / 'skills' / 'new-skill'
338
+ new_skill_dir.mkdir(parents=True, exist_ok=True)
339
+ (new_skill_dir / 'SKILL.md').write_text(
340
+ '---\nname: new-skill\ndescription: a new skill\n---\n# new-skill\n'
341
+ )
342
+ subprocess.run(['git', 'add', '.'], cwd=repo, capture_output=True, check=True)
343
+ subprocess.run(['git', 'commit', '-m', 'add new-skill'], cwd=repo, capture_output=True, check=True)
344
+
345
+ # Now request both skills — new-skill won't be in the stale clone
346
+ _write_config(tmp_path, [{'repo': str(repo), 'skills': ['old-skill', 'new-skill']}])
347
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
348
+ assert result.exit_code == 0, result.output
349
+
350
+ # new-skill should be found after auto-pull, no warning
351
+ assert 'Warning: skills not found' not in result.output
352
+ assert (tmp_path / 'agents' / 'claude' / 'new-skill').is_symlink()
353
+
354
+ lock = _load_lock(tmp_path)
355
+ names = {s['name'] for s in lock['skills']}
356
+ assert names == {'old-skill', 'new-skill'}
357
+
267
358
 
268
359
  class TestList:
269
360
  def test_list_empty(self, tmp_path):
@@ -360,6 +451,26 @@ class TestUpdate:
360
451
  assert result.exit_code == 0
361
452
  assert 'Already up to date' in result.output
362
453
 
454
+ def test_update_passes_clone_strategy_to_git_helper(self, tmp_path, monkeypatch):
455
+ repo = _make_skill_repo(tmp_path, 'repo-upd-shallow', [{'name': 'upd-skill'}])
456
+ _write_config(tmp_path, [{'repo': str(repo), 'clone_strategy': 'shallow'}])
457
+
458
+ runner = CliRunner()
459
+ install_result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
460
+ assert install_result.exit_code == 0, install_result.output
461
+
462
+ clone_strategies = []
463
+
464
+ def fake_clone_or_pull(repo_url, dest, clone_strategy=None):
465
+ clone_strategies.append(clone_strategy)
466
+ subprocess.run(['git', 'pull', '--ff-only'], cwd=dest, capture_output=True, check=True)
467
+
468
+ monkeypatch.setattr('skm.commands.update.clone_or_pull', fake_clone_or_pull)
469
+
470
+ result = runner.invoke(cli, [*_cli_args(tmp_path), 'update', 'upd-skill'])
471
+ assert result.exit_code == 0, result.output
472
+ assert clone_strategies == ['shallow']
473
+
363
474
  def test_update_with_new_commit(self, tmp_path):
364
475
  repo = _make_skill_repo(tmp_path, 'repo-upd2', [{'name': 'upd-skill'}])
365
476
  _write_config(tmp_path, [{'repo': str(repo)}])
@@ -7,6 +7,7 @@ from skm.types import SkillRepoConfig
7
7
  EXAMPLE_YAML = """\
8
8
  packages:
9
9
  - repo: https://github.com/vercel-labs/agent-skills
10
+ clone_strategy: shallow
10
11
  skills:
11
12
  - react-best-practices
12
13
  - web-design-guidelines
@@ -33,6 +34,7 @@ def test_load_config(tmp_path):
33
34
  config = load_config(config_file)
34
35
  assert len(config.packages) == 2
35
36
  assert config.packages[0].repo == 'https://github.com/vercel-labs/agent-skills'
37
+ assert config.packages[0].clone_strategy == 'shallow'
36
38
  assert config.packages[0].skills == ['react-best-practices', 'web-design-guidelines']
37
39
  assert config.packages[0].agents.excludes == ['openclaw']
38
40
  assert config.packages[1].repo == 'https://github.com/blader/humanizer'
@@ -80,6 +80,36 @@ def test_detect_skills_skips_git_dir(tmp_path):
80
80
  assert skills[0].name == 'real-skill'
81
81
 
82
82
 
83
+ def test_detect_skills_in_explicit_skills_dir(tmp_path):
84
+ """Explicit skills_dir limits discovery to that directory."""
85
+ _write_skill_md(tmp_path / 'skills' / 'ignored', 'ignored')
86
+ _write_skill_md(tmp_path / 'certain' / 'relative' / 'path' / 'skill-a', 'skill-a')
87
+ _write_skill_md(tmp_path / 'certain' / 'relative' / 'path' / 'nested' / 'skill-b', 'skill-b')
88
+
89
+ skills = detect_skills(tmp_path, skills_dir='certain/relative/path')
90
+ names = {s.name for s in skills}
91
+ assert names == {'skill-a', 'skill-b'}
92
+ assert {s.relative_path for s in skills} == {
93
+ 'certain/relative/path/skill-a',
94
+ 'certain/relative/path/nested/skill-b',
95
+ }
96
+
97
+
98
+ def test_detect_explicit_skills_dir_can_be_singleton(tmp_path):
99
+ """skills_dir may point directly at one skill directory."""
100
+ _write_skill_md(tmp_path / 'packages' / 'one-skill', 'one-skill')
101
+
102
+ skills = detect_skills(tmp_path, skills_dir='packages/one-skill')
103
+ assert len(skills) == 1
104
+ assert skills[0].name == 'one-skill'
105
+ assert skills[0].relative_path == 'packages/one-skill'
106
+
107
+
108
+ def test_detect_missing_explicit_skills_dir_raises(tmp_path):
109
+ with pytest.raises(ValueError, match='skills_dir does not exist'):
110
+ detect_skills(tmp_path, skills_dir='missing')
111
+
112
+
83
113
  def test_parse_skill_name_strips_quotes(tmp_path):
84
114
  """Quoted name values in frontmatter should have quotes stripped."""
85
115
  from skm.detect import parse_skill_name
@@ -34,6 +34,54 @@ def test_clone_and_get_commit(tmp_path):
34
34
  clone_or_pull(str(src), dest)
35
35
 
36
36
 
37
+ def test_clone_uses_partial_clone_by_default(tmp_path, monkeypatch):
38
+ calls = []
39
+
40
+ def fake_run_cmd(cmd, **kwargs):
41
+ calls.append((cmd, kwargs))
42
+ return subprocess.CompletedProcess(cmd, 0, stdout="")
43
+
44
+ monkeypatch.setattr("skm.git.run_cmd", fake_run_cmd)
45
+
46
+ dest = tmp_path / "store" / "repo"
47
+ clone_or_pull("https://github.com/example/repo", dest)
48
+
49
+ assert calls == [
50
+ (
51
+ ["git", "clone", "--filter=blob:none", "https://github.com/example/repo", str(dest)],
52
+ {},
53
+ )
54
+ ]
55
+
56
+
57
+ def test_clone_uses_depth_for_shallow_strategy(tmp_path, monkeypatch):
58
+ calls = []
59
+
60
+ def fake_run_cmd(cmd, **kwargs):
61
+ calls.append((cmd, kwargs))
62
+ return subprocess.CompletedProcess(cmd, 0, stdout="")
63
+
64
+ monkeypatch.setattr("skm.git.run_cmd", fake_run_cmd)
65
+
66
+ dest = tmp_path / "store" / "repo"
67
+ clone_or_pull("https://github.com/example/repo", dest, clone_strategy="shallow")
68
+
69
+ assert calls == [
70
+ (
71
+ [
72
+ "git",
73
+ "clone",
74
+ "--filter=blob:none",
75
+ "--depth",
76
+ "1",
77
+ "https://github.com/example/repo",
78
+ str(dest),
79
+ ],
80
+ {},
81
+ )
82
+ ]
83
+
84
+
37
85
  @pytest.mark.network
38
86
  def test_clone_real_repo(tmp_path):
39
87
  """Clone a real GitHub repo successfully."""
@@ -63,6 +63,73 @@ def test_install_basic(tmp_path):
63
63
  assert lock_path.exists()
64
64
 
65
65
 
66
+ def test_install_passes_clone_strategy_to_git_helper(tmp_path, monkeypatch):
67
+ repo = _make_skill_repo(tmp_path, 'shallow-skill')
68
+
69
+ config_path = tmp_path / 'config' / 'skills.yaml'
70
+ config_path.parent.mkdir(parents=True)
71
+ config_path.write_text(
72
+ f'packages:\n - repo: {repo}\n clone_strategy: shallow\n skills:\n - shallow-skill\n'
73
+ )
74
+
75
+ lock_path = tmp_path / 'config' / 'skills-lock.yaml'
76
+ store_dir = tmp_path / 'store'
77
+ agents = {'claude': str(tmp_path / 'agents' / 'claude' / 'skills')}
78
+ clone_strategies = []
79
+
80
+ def fake_clone_or_pull(repo_url, dest, clone_strategy=None):
81
+ clone_strategies.append(clone_strategy)
82
+ subprocess.run(['git', 'clone', repo_url, str(dest)], capture_output=True, check=True)
83
+
84
+ monkeypatch.setattr('skm.commands.install.clone_or_pull', fake_clone_or_pull)
85
+
86
+ config = load_config(config_path)
87
+ run_install(config=config, lock_path=lock_path, store_dir=store_dir, known_agents=agents)
88
+
89
+ assert clone_strategies == ['shallow']
90
+ assert (tmp_path / 'agents' / 'claude' / 'skills' / 'shallow-skill').is_symlink()
91
+
92
+
93
+ def test_install_with_skills_dir_limits_detection(tmp_path):
94
+ """Install only skills below a package's explicit skills_dir."""
95
+ repo = tmp_path / 'repo-skills-dir'
96
+ repo.mkdir()
97
+ subprocess.run(['git', 'init'], cwd=repo, capture_output=True)
98
+ subprocess.run(['git', 'config', 'user.email', 't@t.com'], cwd=repo, capture_output=True)
99
+ subprocess.run(['git', 'config', 'user.name', 'T'], cwd=repo, capture_output=True)
100
+
101
+ wanted = repo / 'certain' / 'relative' / 'path' / 'wanted'
102
+ wanted.mkdir(parents=True)
103
+ (wanted / 'SKILL.md').write_text('---\nname: wanted\ndescription: test\n---\nContent\n')
104
+
105
+ ignored = repo / 'skills' / 'ignored'
106
+ ignored.mkdir(parents=True)
107
+ (ignored / 'SKILL.md').write_text('---\nname: ignored\ndescription: test\n---\nContent\n')
108
+
109
+ subprocess.run(['git', 'add', '.'], cwd=repo, capture_output=True)
110
+ subprocess.run(['git', 'commit', '-m', 'init'], cwd=repo, capture_output=True)
111
+
112
+ config_path = tmp_path / 'config' / 'skills.yaml'
113
+ config_path.parent.mkdir(parents=True)
114
+ config_path.write_text(
115
+ f'packages:\n - repo: {repo}\n skills_dir: certain/relative/path\n'
116
+ )
117
+
118
+ lock_path = tmp_path / 'config' / 'skills-lock.yaml'
119
+ store_dir = tmp_path / 'store'
120
+ agents = {'claude': str(tmp_path / 'agents' / 'claude' / 'skills')}
121
+
122
+ config = load_config(config_path)
123
+ run_install(config=config, lock_path=lock_path, store_dir=store_dir, known_agents=agents)
124
+
125
+ assert (tmp_path / 'agents' / 'claude' / 'skills' / 'wanted').is_symlink()
126
+ assert not (tmp_path / 'agents' / 'claude' / 'skills' / 'ignored').exists()
127
+
128
+ lock = load_lock(lock_path)
129
+ assert [s.name for s in lock.skills] == ['wanted']
130
+ assert lock.skills[0].skill_path == 'certain/relative/path/wanted'
131
+
132
+
66
133
  def test_install_singleton_skill(tmp_path):
67
134
  """Install a singleton skill (SKILL.md at repo root)."""
68
135
  repo = _make_skill_repo(tmp_path, 'singleton', skills_subdir=False)
@@ -0,0 +1,83 @@
1
+ import pytest
2
+ from skm.types import SkillRepoConfig, AgentsConfig
3
+
4
+
5
+ def test_skill_repo_config_minimal():
6
+ """Repo with no skills listed and no agents config."""
7
+ cfg = SkillRepoConfig(repo="https://github.com/blader/humanizer")
8
+ assert cfg.repo == "https://github.com/blader/humanizer"
9
+ assert cfg.skills is None
10
+ assert cfg.agents is None
11
+
12
+
13
+ def test_skill_repo_config_with_skills():
14
+ cfg = SkillRepoConfig(
15
+ repo="https://github.com/vercel-labs/agent-skills",
16
+ skills=["react-best-practices", "react-native-skills"],
17
+ )
18
+ assert cfg.skills == ["react-best-practices", "react-native-skills"]
19
+
20
+
21
+ def test_skill_repo_config_with_skills_excludes():
22
+ cfg = SkillRepoConfig(
23
+ local_path="~/Code/devblocks",
24
+ skills_excludes=["team-squad", "team-council"],
25
+ )
26
+ assert cfg.skills_excludes == ["team-squad", "team-council"]
27
+ assert cfg.skills is None
28
+
29
+
30
+ def test_skill_repo_config_rejects_skills_and_skills_excludes():
31
+ with pytest.raises(ValueError):
32
+ SkillRepoConfig(
33
+ repo="https://github.com/example/repo",
34
+ skills=["a"],
35
+ skills_excludes=["b"],
36
+ )
37
+
38
+
39
+ def test_skill_repo_config_with_skills_dir():
40
+ cfg = SkillRepoConfig(
41
+ repo="https://github.com/example/repo",
42
+ skills_dir="certain/relative/path",
43
+ )
44
+ assert cfg.skills_dir == "certain/relative/path"
45
+
46
+
47
+ def test_skill_repo_config_with_clone_strategy():
48
+ cfg = SkillRepoConfig(
49
+ repo="https://github.com/example/repo",
50
+ clone_strategy="shallow",
51
+ )
52
+ assert cfg.clone_strategy == "shallow"
53
+
54
+
55
+ def test_skill_repo_config_rejects_absolute_skills_dir():
56
+ with pytest.raises(ValueError):
57
+ SkillRepoConfig(repo="https://github.com/example/repo", skills_dir="/tmp/skills")
58
+
59
+
60
+ def test_skill_repo_config_rejects_parent_traversal_skills_dir():
61
+ with pytest.raises(ValueError):
62
+ SkillRepoConfig(repo="https://github.com/example/repo", skills_dir="../skills")
63
+
64
+
65
+ def test_agents_config_excludes():
66
+ agents = AgentsConfig(excludes=["openclaw"])
67
+ assert agents.excludes == ["openclaw"]
68
+ assert agents.includes is None
69
+
70
+
71
+ def test_agents_config_includes():
72
+ agents = AgentsConfig(includes=["claude", "codex"])
73
+ assert agents.includes == ["claude", "codex"]
74
+ assert agents.excludes is None
75
+
76
+
77
+ def test_skill_repo_config_with_agents():
78
+ cfg = SkillRepoConfig(
79
+ repo="https://github.com/vercel-labs/agent-skills",
80
+ skills=["react-best-practices"],
81
+ agents=AgentsConfig(excludes=["openclaw"]),
82
+ )
83
+ assert cfg.agents.excludes == ["openclaw"]
@@ -181,7 +181,7 @@ wheels = [
181
181
 
182
182
  [[package]]
183
183
  name = "skm-cli"
184
- version = "0.2.0"
184
+ version = "0.2.2"
185
185
  source = { editable = "." }
186
186
  dependencies = [
187
187
  { name = "click" },
@@ -1,38 +0,0 @@
1
- agents:
2
- default:
3
- - claude
4
- - standard
5
- - codex
6
- - openclaw
7
-
8
- packages:
9
- - repo: https://github.com/vercel-labs/agent-skills
10
- skills:
11
- - vercel-react-best-practices
12
- - web-design-guidelines
13
- agents:
14
- excludes:
15
- - openclaw
16
- - repo: https://github.com/ibelick/ui-skills
17
- skills:
18
- - baseline-ui
19
- - repo: https://github.com/OthmanAdi/planning-with-files
20
- skills:
21
- - planning-with-files
22
- agents:
23
- excludes:
24
- - openclaw
25
- - repo: https://github.com/blader/humanizer
26
- - repo: http://github.com/better-auth/skills
27
- skills:
28
- - better-auth-best-practices
29
- - create-auth-skill
30
- - repo: https://github.com/PsiACE/skills
31
- skills:
32
- - friendly-python
33
- - repo: https://github.com/SawyerHood/dev-browser
34
- skills:
35
- - dev-browser
36
- agents:
37
- excludes:
38
- - openclaw
@@ -1,39 +0,0 @@
1
- import pytest
2
- from skm.types import SkillRepoConfig, AgentsConfig
3
-
4
-
5
- def test_skill_repo_config_minimal():
6
- """Repo with no skills listed and no agents config."""
7
- cfg = SkillRepoConfig(repo="https://github.com/blader/humanizer")
8
- assert cfg.repo == "https://github.com/blader/humanizer"
9
- assert cfg.skills is None
10
- assert cfg.agents is None
11
-
12
-
13
- def test_skill_repo_config_with_skills():
14
- cfg = SkillRepoConfig(
15
- repo="https://github.com/vercel-labs/agent-skills",
16
- skills=["react-best-practices", "react-native-skills"],
17
- )
18
- assert cfg.skills == ["react-best-practices", "react-native-skills"]
19
-
20
-
21
- def test_agents_config_excludes():
22
- agents = AgentsConfig(excludes=["openclaw"])
23
- assert agents.excludes == ["openclaw"]
24
- assert agents.includes is None
25
-
26
-
27
- def test_agents_config_includes():
28
- agents = AgentsConfig(includes=["claude", "codex"])
29
- assert agents.includes == ["claude", "codex"]
30
- assert agents.excludes is None
31
-
32
-
33
- def test_skill_repo_config_with_agents():
34
- cfg = SkillRepoConfig(
35
- repo="https://github.com/vercel-labs/agent-skills",
36
- skills=["react-best-practices"],
37
- agents=AgentsConfig(excludes=["openclaw"]),
38
- )
39
- assert cfg.agents.excludes == ["openclaw"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes