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.
- {skm_cli-0.2.0 → skm_cli-0.2.2}/AGENTS.md +3 -0
- skm_cli-0.2.2/CLAUDE.md +1 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/PKG-INFO +57 -1
- {skm_cli-0.2.0 → skm_cli-0.2.2}/README.md +56 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/pyproject.toml +1 -1
- skm_cli-0.2.2/skills.example.yaml +76 -0
- skm_cli-0.2.2/src/skm/clonefile.py +106 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/install.py +36 -5
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/update.py +16 -12
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/detect.py +23 -11
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/git.py +6 -2
- skm_cli-0.2.2/src/skm/linker.py +195 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/types.py +22 -1
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_cli_e2e.py +139 -0
- skm_cli-0.2.2/tests/test_clonefile.py +178 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_config.py +2 -0
- skm_cli-0.2.2/tests/test_detect.py +155 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_git.py +48 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_install.py +67 -0
- skm_cli-0.2.2/tests/test_linker.py +280 -0
- skm_cli-0.2.2/tests/test_types.py +83 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/uv.lock +1 -1
- skm_cli-0.2.0/skills.example.yaml +0 -38
- skm_cli-0.2.0/src/skm/linker.py +0 -116
- skm_cli-0.2.0/tests/test_detect.py +0 -81
- skm_cli-0.2.0/tests/test_linker.py +0 -142
- skm_cli-0.2.0/tests/test_types.py +0 -39
- {skm_cli-0.2.0 → skm_cli-0.2.2}/.gitignore +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/draft.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/images/skm-install.png +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/link-skill +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/__init__.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/cli.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/__init__.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/check_updates.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/list_cmd.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/remove.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/commands/view.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/config.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/lock.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/py.typed +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/tui.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/src/skm/utils.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_install_from_source.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_list_cmd.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.2}/tests/test_local_path.py +0 -0
- {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
|
|
skm_cli-0.2.2/CLAUDE.md
ADDED
|
@@ -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.
|
|
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:
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -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=
|
|
10
|
-
match = re.match(r
|
|
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
|
|
12
|
+
raise ValueError(f'No frontmatter found in {skill_md_path}')
|
|
13
13
|
for line in match.group(1).splitlines():
|
|
14
|
-
if line.startswith(
|
|
15
|
-
return line.split(
|
|
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 /
|
|
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 /
|
|
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.
|
|
52
|
+
if child.name == '.git':
|
|
41
53
|
continue
|
|
42
|
-
skill_md = child /
|
|
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
|
-
|
|
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:
|