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.
- {skm_cli-0.2.1 → skm_cli-0.2.2}/AGENTS.md +3 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/PKG-INFO +16 -1
- {skm_cli-0.2.1 → skm_cli-0.2.2}/README.md +15 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/pyproject.toml +1 -1
- skm_cli-0.2.2/skills.example.yaml +76 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/install.py +36 -5
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/update.py +15 -11
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/detect.py +13 -1
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/git.py +6 -2
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/types.py +22 -1
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_cli_e2e.py +111 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_config.py +2 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_detect.py +30 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_git.py +48 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_install.py +67 -0
- skm_cli-0.2.2/tests/test_types.py +83 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/uv.lock +1 -1
- skm_cli-0.2.1/skills.example.yaml +0 -38
- skm_cli-0.2.1/tests/test_types.py +0 -39
- {skm_cli-0.2.1 → skm_cli-0.2.2}/.gitignore +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/CLAUDE.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/draft.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/images/skm-install.png +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/link-skill +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/__init__.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/cli.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/clonefile.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/__init__.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/check_updates.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/list_cmd.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/remove.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/commands/view.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/config.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/linker.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/lock.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/py.typed +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/tui.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/src/skm/utils.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_clonefile.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_install_from_source.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_linker.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_list_cmd.py +0 -0
- {skm_cli-0.2.1 → skm_cli-0.2.2}/tests/test_local_path.py +0 -0
- {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.
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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"]
|
|
@@ -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
|
{skm_cli-0.2.1 → skm_cli-0.2.2}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|