skm-cli 0.2.0__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 (54) hide show
  1. {skm_cli-0.2.0 → skm_cli-0.2.2}/AGENTS.md +3 -0
  2. skm_cli-0.2.2/CLAUDE.md +1 -0
  3. {skm_cli-0.2.0 → skm_cli-0.2.2}/PKG-INFO +57 -1
  4. {skm_cli-0.2.0 → skm_cli-0.2.2}/README.md +56 -0
  5. {skm_cli-0.2.0 → skm_cli-0.2.2}/pyproject.toml +1 -1
  6. skm_cli-0.2.2/skills.example.yaml +76 -0
  7. skm_cli-0.2.2/src/skm/clonefile.py +106 -0
  8. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/install.py +36 -5
  9. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/update.py +16 -12
  10. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/detect.py +23 -11
  11. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/git.py +6 -2
  12. skm_cli-0.2.2/src/skm/linker.py +195 -0
  13. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/types.py +22 -1
  14. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_cli_e2e.py +139 -0
  15. skm_cli-0.2.2/tests/test_clonefile.py +178 -0
  16. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_config.py +2 -0
  17. skm_cli-0.2.2/tests/test_detect.py +155 -0
  18. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_git.py +48 -0
  19. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_install.py +67 -0
  20. skm_cli-0.2.2/tests/test_linker.py +280 -0
  21. skm_cli-0.2.2/tests/test_types.py +83 -0
  22. {skm_cli-0.2.0 → skm_cli-0.2.2}/uv.lock +1 -1
  23. skm_cli-0.2.0/skills.example.yaml +0 -38
  24. skm_cli-0.2.0/src/skm/linker.py +0 -116
  25. skm_cli-0.2.0/tests/test_detect.py +0 -81
  26. skm_cli-0.2.0/tests/test_linker.py +0 -142
  27. skm_cli-0.2.0/tests/test_types.py +0 -39
  28. {skm_cli-0.2.0 → skm_cli-0.2.2}/.gitignore +0 -0
  29. {skm_cli-0.2.0 → skm_cli-0.2.2}/draft.md +0 -0
  30. {skm_cli-0.2.0 → skm_cli-0.2.2}/images/skm-install.png +0 -0
  31. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
  32. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
  33. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
  34. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
  35. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
  36. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
  37. {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
  38. {skm_cli-0.2.0 → skm_cli-0.2.2}/link-skill +0 -0
  39. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/__init__.py +0 -0
  40. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/cli.py +0 -0
  41. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/__init__.py +0 -0
  42. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/check_updates.py +0 -0
  43. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/list_cmd.py +0 -0
  44. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/remove.py +0 -0
  45. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/view.py +0 -0
  46. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/config.py +0 -0
  47. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/lock.py +0 -0
  48. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/py.typed +0 -0
  49. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/tui.py +0 -0
  50. {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/utils.py +0 -0
  51. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_install_from_source.py +0 -0
  52. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_list_cmd.py +0 -0
  53. {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_local_path.py +0 -0
  54. {skm_cli-0.2.0 → 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
 
@@ -0,0 +1 @@
1
+ AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skm-cli
3
- Version: 0.2.0
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:
@@ -141,6 +156,47 @@ Skills are symlinked into these directories by default:
141
156
  | `codex` | `~/.codex/skills/` |
142
157
  | `openclaw` | `~/.openclaw/skills/` |
143
158
 
159
+ ## Copy Strategy
160
+
161
+ When skm links a skill into an agent directory, it picks a strategy based on the agent config and filesystem:
162
+
163
+ ### 1. Symlink (default)
164
+
165
+ A symbolic link from `<agent_dir>/skills/<skill_name>` → `<store>/<skill_name>`. This is the default for all agents. Changes in the store are immediately visible.
166
+
167
+ ### 2. Hardlink
168
+
169
+ When `use_hardlink: true` is set for an agent in `AGENT_OPTIONS`, skm creates hardlinks instead. Each file in the skill directory gets its own hardlink pointing to the same inode as the source. This only works when source and target are on the **same filesystem/device**.
170
+
171
+ ### 3. Reflink (copy-on-write)
172
+
173
+ When hardlinks can't be used (source and target on **different devices**), skm attempts a reflink/COW clone. This creates an independent copy that shares physical disk blocks with the source until either side is modified — fast and space-efficient.
174
+
175
+ The reflink backend is platform-specific:
176
+
177
+ | Platform | Mechanism | Supported filesystems |
178
+ |---|---|---|
179
+ | **Linux** | `FICLONE` ioctl (`fcntl.ioctl`) | Btrfs, XFS, OCFS2, and others with reflink support |
180
+ | **macOS** | `clonefile(2)` syscall (via `ctypes`) | APFS (default since macOS 10.13) |
181
+
182
+ ### 4. Plain copy (fallback)
183
+
184
+ If reflink is not available (unsupported filesystem, non-Unix platform, etc.), skm falls back to a plain `shutil.copy2` — a full byte copy with metadata preserved.
185
+
186
+ ### Selection flow
187
+
188
+ ```
189
+ use_hardlink enabled?
190
+ ├── No → symlink
191
+ └── Yes → same device?
192
+ ├── Yes → hardlink
193
+ └── No → reflink supported?
194
+ ├── Yes → reflink (COW clone)
195
+ └── No → plain copy
196
+ ```
197
+
198
+ The reflink implementation is isolated in `src/skm/clonefile.py` with dedicated tests in `tests/test_clonefile.py`.
199
+
144
200
  ## CLI Path Overrides
145
201
 
146
202
  Override default paths for testing or custom setups:
@@ -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:
@@ -130,6 +145,47 @@ Skills are symlinked into these directories by default:
130
145
  | `codex` | `~/.codex/skills/` |
131
146
  | `openclaw` | `~/.openclaw/skills/` |
132
147
 
148
+ ## Copy Strategy
149
+
150
+ When skm links a skill into an agent directory, it picks a strategy based on the agent config and filesystem:
151
+
152
+ ### 1. Symlink (default)
153
+
154
+ A symbolic link from `<agent_dir>/skills/<skill_name>` → `<store>/<skill_name>`. This is the default for all agents. Changes in the store are immediately visible.
155
+
156
+ ### 2. Hardlink
157
+
158
+ When `use_hardlink: true` is set for an agent in `AGENT_OPTIONS`, skm creates hardlinks instead. Each file in the skill directory gets its own hardlink pointing to the same inode as the source. This only works when source and target are on the **same filesystem/device**.
159
+
160
+ ### 3. Reflink (copy-on-write)
161
+
162
+ When hardlinks can't be used (source and target on **different devices**), skm attempts a reflink/COW clone. This creates an independent copy that shares physical disk blocks with the source until either side is modified — fast and space-efficient.
163
+
164
+ The reflink backend is platform-specific:
165
+
166
+ | Platform | Mechanism | Supported filesystems |
167
+ |---|---|---|
168
+ | **Linux** | `FICLONE` ioctl (`fcntl.ioctl`) | Btrfs, XFS, OCFS2, and others with reflink support |
169
+ | **macOS** | `clonefile(2)` syscall (via `ctypes`) | APFS (default since macOS 10.13) |
170
+
171
+ ### 4. Plain copy (fallback)
172
+
173
+ If reflink is not available (unsupported filesystem, non-Unix platform, etc.), skm falls back to a plain `shutil.copy2` — a full byte copy with metadata preserved.
174
+
175
+ ### Selection flow
176
+
177
+ ```
178
+ use_hardlink enabled?
179
+ ├── No → symlink
180
+ └── Yes → same device?
181
+ ├── Yes → hardlink
182
+ └── No → reflink supported?
183
+ ├── Yes → reflink (COW clone)
184
+ └── No → plain copy
185
+ ```
186
+
187
+ The reflink implementation is isolated in `src/skm/clonefile.py` with dedicated tests in `tests/test_clonefile.py`.
188
+
133
189
  ## CLI Path Overrides
134
190
 
135
191
  Override default paths for testing or custom setups:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skm-cli"
3
- version = "0.2.0"
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
@@ -0,0 +1,106 @@
1
+ """Platform-aware reflink (copy-on-write) file cloning.
2
+
3
+ Supports two backends:
4
+ - Linux: FICLONE ioctl (Btrfs, XFS, etc.)
5
+ - macOS: clonefile(2) syscall (APFS)
6
+
7
+ Both backends raise OSError on failure. Callers should catch OSError and
8
+ check ``is_reflink_unsupported(exc)`` to decide whether to fall back to
9
+ a plain copy.
10
+ """
11
+
12
+ import ctypes
13
+ import ctypes.util
14
+ import errno
15
+ import os
16
+ import platform
17
+ import shutil
18
+ from pathlib import Path
19
+
20
+ # --- errno values that mean "this filesystem doesn't support reflink" -------
21
+
22
+ REFLINK_UNSUPPORTED_ERRNOS: set[int] = {
23
+ errno.ENOTSUP,
24
+ getattr(errno, 'EOPNOTSUPP', errno.ENOTSUP),
25
+ getattr(errno, 'ENOSYS', errno.ENOTSUP),
26
+ getattr(errno, 'ENOTTY', errno.ENOTSUP),
27
+ getattr(errno, 'EXDEV', errno.ENOTSUP),
28
+ }
29
+
30
+ _SYSTEM = platform.system()
31
+
32
+ # --- Linux: FICLONE ioctl ---------------------------------------------------
33
+
34
+ _FICLONE = 0x40049409 # from <linux/fs.h>
35
+ _fcntl = None
36
+
37
+ if _SYSTEM == 'Linux':
38
+ try:
39
+ import fcntl as _fcntl_mod
40
+
41
+ _fcntl = _fcntl_mod
42
+ except ImportError: # pragma: no cover
43
+ pass
44
+
45
+
46
+ def _clone_file_linux(src: Path, dst: Path) -> None:
47
+ """Clone via FICLONE ioctl (Linux only)."""
48
+ assert _fcntl is not None
49
+ with src.open('rb') as src_f, dst.open('wb') as dst_f:
50
+ _fcntl.ioctl(dst_f.fileno(), _FICLONE, src_f.fileno())
51
+ shutil.copystat(src, dst)
52
+
53
+
54
+ # --- macOS: clonefile(2) ----------------------------------------------------
55
+
56
+ _clonefile_func = None
57
+
58
+ if _SYSTEM == 'Darwin':
59
+ _libc_path = ctypes.util.find_library('System')
60
+ if _libc_path:
61
+ _libc = ctypes.CDLL(_libc_path, use_errno=True)
62
+ _clonefile_func = _libc.clonefile
63
+ _clonefile_func.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
64
+ _clonefile_func.restype = ctypes.c_int
65
+
66
+
67
+ def _clone_file_darwin(src: Path, dst: Path) -> None:
68
+ """Clone via clonefile(2) syscall (macOS only)."""
69
+ assert _clonefile_func is not None
70
+ ret = _clonefile_func(os.fsencode(src), os.fsencode(dst), 0)
71
+ if ret != 0:
72
+ err = ctypes.get_errno()
73
+ raise OSError(err, os.strerror(err), str(src))
74
+ shutil.copystat(src, dst)
75
+
76
+
77
+ # --- Public API --------------------------------------------------------------
78
+
79
+
80
+ def reflink_supported() -> bool:
81
+ """Return True if the current platform has a reflink backend available."""
82
+ if _SYSTEM == 'Linux':
83
+ return _fcntl is not None
84
+ if _SYSTEM == 'Darwin':
85
+ return _clonefile_func is not None
86
+ return False
87
+
88
+
89
+ def clone_file(src: Path, dst: Path) -> None:
90
+ """Clone a single file using the platform's COW mechanism.
91
+
92
+ Raises OSError if the filesystem or platform doesn't support it.
93
+ Use ``is_reflink_unsupported(exc)`` to distinguish "not supported"
94
+ from genuine I/O errors.
95
+ """
96
+ if _SYSTEM == 'Linux' and _fcntl is not None:
97
+ _clone_file_linux(src, dst)
98
+ elif _SYSTEM == 'Darwin' and _clonefile_func is not None:
99
+ _clone_file_darwin(src, dst)
100
+ else:
101
+ raise OSError(errno.ENOTSUP, 'reflink is not supported on this platform')
102
+
103
+
104
+ def is_reflink_unsupported(exc: OSError) -> bool:
105
+ """Return True when the error means reflink isn't available, not a real I/O failure."""
106
+ return exc.errno in REFLINK_UNSUPPORTED_ERRNOS
@@ -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,
@@ -128,7 +132,7 @@ def _update_repo(
128
132
  skill = matching[0]
129
133
  linked_paths = []
130
134
  for agent_name, agent_dir in target_agents.items():
131
- link, _status = link_skill(skill.path, skill.name, agent_dir, agent_name=agent_name)
135
+ link, _status = link_skill(skill.path, skill.name, agent_dir, force=True, agent_name=agent_name)
132
136
  linked_paths.append(compact_path(str(link)))
133
137
 
134
138
  lock.skills[i] = InstalledSkill(
@@ -6,26 +6,38 @@ from skm.types import DetectedSkill
6
6
 
7
7
  def parse_skill_name(skill_md_path: Path) -> str:
8
8
  """Extract 'name' from SKILL.md YAML frontmatter."""
9
- text = skill_md_path.read_text(encoding="utf-8")
10
- match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
9
+ text = skill_md_path.read_text(encoding='utf-8')
10
+ match = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
11
11
  if not match:
12
- raise ValueError(f"No frontmatter found in {skill_md_path}")
12
+ raise ValueError(f'No frontmatter found in {skill_md_path}')
13
13
  for line in match.group(1).splitlines():
14
- if line.startswith("name:"):
15
- return line.split(":", 1)[1].strip()
14
+ if line.startswith('name:'):
15
+ return line.split(':', 1)[1].strip().strip('\'"')
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
- root_skill = repo_path / "SKILL.md"
34
+ root_skill = repo_path / 'SKILL.md'
23
35
  if root_skill.exists():
24
36
  name = parse_skill_name(root_skill)
25
- return [DetectedSkill(name=name, path=repo_path, relative_path=".")]
37
+ return [DetectedSkill(name=name, path=repo_path, relative_path='.')]
26
38
 
27
39
  # Determine walk root
28
- skills_dir = repo_path / "skills"
40
+ skills_dir = repo_path / 'skills'
29
41
  walk_root = skills_dir if skills_dir.is_dir() else repo_path
30
42
 
31
43
  return _walk_for_skills(walk_root, repo_path)
@@ -37,9 +49,9 @@ def _walk_for_skills(walk_root: Path, repo_path: Path) -> list[DetectedSkill]:
37
49
  for child in sorted(walk_root.iterdir()):
38
50
  if not child.is_dir() or child.is_symlink():
39
51
  continue
40
- if child.name.startswith("."):
52
+ if child.name == '.git':
41
53
  continue
42
- skill_md = child / "SKILL.md"
54
+ skill_md = child / 'SKILL.md'
43
55
  if skill_md.exists():
44
56
  name = parse_skill_name(skill_md)
45
57
  relative = str(child.relative_to(repo_path))
@@ -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: