skm-cli 0.1.0__tar.gz → 0.2.1__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/CLAUDE.md +1 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/PKG-INFO +49 -1
- {skm_cli-0.1.0 → skm_cli-0.2.1}/README.md +48 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/pyproject.toml +1 -1
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/cli.py +51 -4
- skm_cli-0.2.1/src/skm/clonefile.py +106 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/check_updates.py +10 -7
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/update.py +57 -27
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/detect.py +10 -10
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/git.py +1 -1
- skm_cli-0.2.1/src/skm/linker.py +195 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_cli_e2e.py +28 -0
- skm_cli-0.2.1/tests/test_clonefile.py +178 -0
- skm_cli-0.2.1/tests/test_detect.py +125 -0
- skm_cli-0.2.1/tests/test_linker.py +280 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_local_path.py +1 -1
- {skm_cli-0.1.0 → skm_cli-0.2.1}/uv.lock +2 -2
- skm_cli-0.1.0/src/skm/linker.py +0 -116
- skm_cli-0.1.0/tests/test_detect.py +0 -81
- skm_cli-0.1.0/tests/test_linker.py +0 -142
- {skm_cli-0.1.0 → skm_cli-0.2.1}/.gitignore +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/AGENTS.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/draft.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/images/skm-install.png +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/link-skill +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/skills.example.yaml +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/__init__.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/__init__.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/install.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/list_cmd.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/remove.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/view.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/config.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/lock.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/py.typed +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/tui.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/types.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/utils.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_config.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_git.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_install.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_install_from_source.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_list_cmd.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_lock.py +0 -0
- {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_types.py +0 -0
skm_cli-0.2.1/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.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -19,6 +19,12 @@ A CLI tool that manages **global** AI agent skills from GitHub repos or local di
|
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install skm-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install from source:
|
|
27
|
+
|
|
22
28
|
```bash
|
|
23
29
|
uv tool install git+https://github.com/reorx/skm
|
|
24
30
|
```
|
|
@@ -74,6 +80,7 @@ This detects available skills, lets you pick which ones to install (unless a spe
|
|
|
74
80
|
| `skm list` | Show installed skills and their linked paths. |
|
|
75
81
|
| `skm list --all` | Show all skills across all agent directories, marking which are managed by skm. |
|
|
76
82
|
| `skm view <source>` | Browse and preview skills from a repo URL or local path without installing. |
|
|
83
|
+
| `skm edit` | Open `skills.yaml` in `$EDITOR` (falls back to system default). Shows diff after editing. |
|
|
77
84
|
| `skm check-updates` | Fetch remotes and show available updates (skips `local_path` packages). |
|
|
78
85
|
| `skm update <skill>` | Pull latest for a skill's repo, re-detect, re-link, update lock (skips `local_path` packages). |
|
|
79
86
|
|
|
@@ -134,6 +141,47 @@ Skills are symlinked into these directories by default:
|
|
|
134
141
|
| `codex` | `~/.codex/skills/` |
|
|
135
142
|
| `openclaw` | `~/.openclaw/skills/` |
|
|
136
143
|
|
|
144
|
+
## Copy Strategy
|
|
145
|
+
|
|
146
|
+
When skm links a skill into an agent directory, it picks a strategy based on the agent config and filesystem:
|
|
147
|
+
|
|
148
|
+
### 1. Symlink (default)
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
### 2. Hardlink
|
|
153
|
+
|
|
154
|
+
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**.
|
|
155
|
+
|
|
156
|
+
### 3. Reflink (copy-on-write)
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
The reflink backend is platform-specific:
|
|
161
|
+
|
|
162
|
+
| Platform | Mechanism | Supported filesystems |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| **Linux** | `FICLONE` ioctl (`fcntl.ioctl`) | Btrfs, XFS, OCFS2, and others with reflink support |
|
|
165
|
+
| **macOS** | `clonefile(2)` syscall (via `ctypes`) | APFS (default since macOS 10.13) |
|
|
166
|
+
|
|
167
|
+
### 4. Plain copy (fallback)
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
### Selection flow
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
use_hardlink enabled?
|
|
175
|
+
├── No → symlink
|
|
176
|
+
└── Yes → same device?
|
|
177
|
+
├── Yes → hardlink
|
|
178
|
+
└── No → reflink supported?
|
|
179
|
+
├── Yes → reflink (COW clone)
|
|
180
|
+
└── No → plain copy
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The reflink implementation is isolated in `src/skm/clonefile.py` with dedicated tests in `tests/test_clonefile.py`.
|
|
184
|
+
|
|
137
185
|
## CLI Path Overrides
|
|
138
186
|
|
|
139
187
|
Override default paths for testing or custom setups:
|
|
@@ -8,6 +8,12 @@ A CLI tool that manages **global** AI agent skills from GitHub repos or local di
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install skm-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install from source:
|
|
16
|
+
|
|
11
17
|
```bash
|
|
12
18
|
uv tool install git+https://github.com/reorx/skm
|
|
13
19
|
```
|
|
@@ -63,6 +69,7 @@ This detects available skills, lets you pick which ones to install (unless a spe
|
|
|
63
69
|
| `skm list` | Show installed skills and their linked paths. |
|
|
64
70
|
| `skm list --all` | Show all skills across all agent directories, marking which are managed by skm. |
|
|
65
71
|
| `skm view <source>` | Browse and preview skills from a repo URL or local path without installing. |
|
|
72
|
+
| `skm edit` | Open `skills.yaml` in `$EDITOR` (falls back to system default). Shows diff after editing. |
|
|
66
73
|
| `skm check-updates` | Fetch remotes and show available updates (skips `local_path` packages). |
|
|
67
74
|
| `skm update <skill>` | Pull latest for a skill's repo, re-detect, re-link, update lock (skips `local_path` packages). |
|
|
68
75
|
|
|
@@ -123,6 +130,47 @@ Skills are symlinked into these directories by default:
|
|
|
123
130
|
| `codex` | `~/.codex/skills/` |
|
|
124
131
|
| `openclaw` | `~/.openclaw/skills/` |
|
|
125
132
|
|
|
133
|
+
## Copy Strategy
|
|
134
|
+
|
|
135
|
+
When skm links a skill into an agent directory, it picks a strategy based on the agent config and filesystem:
|
|
136
|
+
|
|
137
|
+
### 1. Symlink (default)
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
### 2. Hardlink
|
|
142
|
+
|
|
143
|
+
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**.
|
|
144
|
+
|
|
145
|
+
### 3. Reflink (copy-on-write)
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
The reflink backend is platform-specific:
|
|
150
|
+
|
|
151
|
+
| Platform | Mechanism | Supported filesystems |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| **Linux** | `FICLONE` ioctl (`fcntl.ioctl`) | Btrfs, XFS, OCFS2, and others with reflink support |
|
|
154
|
+
| **macOS** | `clonefile(2)` syscall (via `ctypes`) | APFS (default since macOS 10.13) |
|
|
155
|
+
|
|
156
|
+
### 4. Plain copy (fallback)
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
### Selection flow
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
use_hardlink enabled?
|
|
164
|
+
├── No → symlink
|
|
165
|
+
└── Yes → same device?
|
|
166
|
+
├── Yes → hardlink
|
|
167
|
+
└── No → reflink supported?
|
|
168
|
+
├── Yes → reflink (COW clone)
|
|
169
|
+
└── No → plain copy
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The reflink implementation is isolated in `src/skm/clonefile.py` with dedicated tests in `tests/test_clonefile.py`.
|
|
173
|
+
|
|
126
174
|
## CLI Path Overrides
|
|
127
175
|
|
|
128
176
|
Override default paths for testing or custom setups:
|
|
@@ -294,17 +294,22 @@ def check_updates(ctx):
|
|
|
294
294
|
|
|
295
295
|
|
|
296
296
|
@cli.command()
|
|
297
|
-
@click.argument('
|
|
297
|
+
@click.argument('skill_names', nargs=-1)
|
|
298
|
+
@click.option('--all', 'update_all', is_flag=True, help='Update all installed skills.')
|
|
298
299
|
@click.pass_context
|
|
299
|
-
def update(ctx,
|
|
300
|
-
"""Update
|
|
300
|
+
def update(ctx, skill_names: tuple[str, ...], update_all: bool):
|
|
301
|
+
"""Update one or more skills (or --all)."""
|
|
301
302
|
from skm.commands.update import run_update
|
|
302
303
|
|
|
304
|
+
if not skill_names and not update_all:
|
|
305
|
+
raise click.UsageError("Provide skill name(s) or use --all.")
|
|
306
|
+
|
|
303
307
|
config = load_config(ctx.obj['config_path'])
|
|
304
308
|
default_agents = config.agents.default if config.agents else None
|
|
305
309
|
agents = _expand_agents(ctx.obj['agents_dir'], default_agents)
|
|
306
310
|
run_update(
|
|
307
|
-
|
|
311
|
+
skill_names=skill_names,
|
|
312
|
+
update_all=update_all,
|
|
308
313
|
config=config,
|
|
309
314
|
lock_path=ctx.obj['lock_path'],
|
|
310
315
|
store_dir=ctx.obj['store_dir'],
|
|
@@ -322,6 +327,48 @@ def view(ctx, source: str):
|
|
|
322
327
|
run_view(source=source, store_dir=ctx.obj['store_dir'])
|
|
323
328
|
|
|
324
329
|
|
|
330
|
+
@cli.command()
|
|
331
|
+
@click.pass_context
|
|
332
|
+
def edit(ctx):
|
|
333
|
+
"""Open skills.yaml in your editor."""
|
|
334
|
+
import os
|
|
335
|
+
import platform
|
|
336
|
+
import subprocess
|
|
337
|
+
|
|
338
|
+
config_path = ctx.obj['config_path']
|
|
339
|
+
if not config_path.exists():
|
|
340
|
+
raise click.ClickException(f'Config file not found: {config_path}')
|
|
341
|
+
|
|
342
|
+
editor = os.environ.get('EDITOR')
|
|
343
|
+
if editor:
|
|
344
|
+
import shutil
|
|
345
|
+
import tempfile
|
|
346
|
+
|
|
347
|
+
tmp = tempfile.NamedTemporaryFile(suffix='.yaml', delete=False)
|
|
348
|
+
tmp.close()
|
|
349
|
+
shutil.copy2(config_path, tmp.name)
|
|
350
|
+
try:
|
|
351
|
+
subprocess.call([editor, str(config_path)])
|
|
352
|
+
if shutil.which('diff'):
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
['diff', '--color=always', tmp.name, str(config_path)],
|
|
355
|
+
capture_output=True,
|
|
356
|
+
text=True,
|
|
357
|
+
)
|
|
358
|
+
if result.stdout:
|
|
359
|
+
click.echo(result.stdout)
|
|
360
|
+
else:
|
|
361
|
+
click.echo('No changes.')
|
|
362
|
+
finally:
|
|
363
|
+
os.unlink(tmp.name)
|
|
364
|
+
elif platform.system() == 'Darwin':
|
|
365
|
+
subprocess.call(['open', str(config_path)])
|
|
366
|
+
elif platform.system() == 'Windows':
|
|
367
|
+
os.startfile(str(config_path))
|
|
368
|
+
else:
|
|
369
|
+
subprocess.call(['xdg-open', str(config_path)])
|
|
370
|
+
|
|
371
|
+
|
|
325
372
|
@cli.command(name='list')
|
|
326
373
|
@click.argument('skill_name', required=False, default=None)
|
|
327
374
|
@click.option(
|
|
@@ -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
|
|
@@ -23,7 +23,10 @@ def run_check_updates(lock_path: Path, store_dir: Path) -> None:
|
|
|
23
23
|
|
|
24
24
|
has_updates = False
|
|
25
25
|
|
|
26
|
-
for repo_url, skills in repos.items():
|
|
26
|
+
for i, (repo_url, skills) in enumerate(repos.items()):
|
|
27
|
+
if i > 0:
|
|
28
|
+
click.echo()
|
|
29
|
+
|
|
27
30
|
repo_dir_name = repo_url_to_dirname(repo_url)
|
|
28
31
|
repo_path = store_dir / repo_dir_name
|
|
29
32
|
|
|
@@ -31,24 +34,24 @@ def run_check_updates(lock_path: Path, store_dir: Path) -> None:
|
|
|
31
34
|
click.echo(click.style(f"Warning: Repo not found locally: {repo_url}", fg="red"))
|
|
32
35
|
continue
|
|
33
36
|
|
|
34
|
-
click.echo(f"Fetching {repo_url}...")
|
|
37
|
+
click.echo(f"Fetching {click.style(repo_url, fg='cyan')}...")
|
|
35
38
|
fetch(repo_path)
|
|
36
39
|
|
|
37
40
|
local_commit = skills[0].commit # all skills from same repo share commit
|
|
38
41
|
remote_commit = get_remote_head_commit(repo_path)
|
|
39
42
|
|
|
40
43
|
if local_commit == remote_commit:
|
|
41
|
-
click.echo(
|
|
44
|
+
click.echo(click.style(" ✔ Up to date", fg="green"))
|
|
42
45
|
continue
|
|
43
46
|
|
|
44
47
|
has_updates = True
|
|
45
48
|
log = get_log_between(repo_path, local_commit, remote_commit)
|
|
46
|
-
skill_names = ", ".join(s.name for s in skills)
|
|
49
|
+
skill_names = ", ".join(click.style(s.name, fg="yellow") for s in skills)
|
|
47
50
|
click.echo(f" Updates available for: {skill_names}")
|
|
48
|
-
click.echo(f" {local_commit[:8]}
|
|
51
|
+
click.echo(f" {click.style(local_commit[:8], fg='red')} → {click.style(remote_commit[:8], fg='green')}")
|
|
49
52
|
if log:
|
|
50
53
|
for line in log.splitlines():
|
|
51
|
-
click.echo(f" {line}")
|
|
54
|
+
click.echo(click.style(f" {line}", dim=True))
|
|
52
55
|
|
|
53
56
|
if not has_updates:
|
|
54
|
-
click.echo("\
|
|
57
|
+
click.echo(click.style("\n✔ All skills are up to date.", fg="green"))
|
|
@@ -12,7 +12,8 @@ from skm.utils import compact_path
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def run_update(
|
|
15
|
-
|
|
15
|
+
skill_names: tuple[str, ...],
|
|
16
|
+
update_all: bool,
|
|
16
17
|
config: SkmConfig,
|
|
17
18
|
lock_path: Path,
|
|
18
19
|
store_dir: Path,
|
|
@@ -20,52 +21,84 @@ def run_update(
|
|
|
20
21
|
) -> None:
|
|
21
22
|
lock = load_lock(lock_path)
|
|
22
23
|
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
# Determine which repos to update
|
|
25
|
+
if update_all:
|
|
26
|
+
repos_to_update: dict[str, list[InstalledSkill]] = {}
|
|
27
|
+
for s in lock.skills:
|
|
28
|
+
if s.repo is None:
|
|
29
|
+
continue
|
|
30
|
+
repos_to_update.setdefault(s.repo, []).append(s)
|
|
31
|
+
else:
|
|
32
|
+
# Find requested skills and collect their repos
|
|
33
|
+
repos_to_update = {}
|
|
34
|
+
for name in skill_names:
|
|
35
|
+
found = None
|
|
36
|
+
for s in lock.skills:
|
|
37
|
+
if s.name == name:
|
|
38
|
+
found = s
|
|
39
|
+
break
|
|
40
|
+
if found is None:
|
|
41
|
+
click.echo(f"Skill '{name}' is not installed.")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
if found.repo is None:
|
|
44
|
+
click.echo(f"Skill '{name}' is from a local path, skipping update.")
|
|
45
|
+
continue
|
|
46
|
+
repos_to_update.setdefault(found.repo, []).append(found)
|
|
47
|
+
|
|
48
|
+
if not repos_to_update:
|
|
49
|
+
click.echo("Nothing to update.")
|
|
50
|
+
return
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
raise SystemExit(1)
|
|
52
|
+
for repo_url, skills in repos_to_update.items():
|
|
53
|
+
_update_repo(repo_url, config, lock, store_dir, known_agents)
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
55
|
+
save_lock(lock, lock_path)
|
|
56
|
+
click.echo('Lock file updated.')
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
|
|
59
|
+
def _update_repo(
|
|
60
|
+
repo_url: str,
|
|
61
|
+
config: SkmConfig,
|
|
62
|
+
lock,
|
|
63
|
+
store_dir: Path,
|
|
64
|
+
known_agents: dict[str, str],
|
|
65
|
+
) -> None:
|
|
66
|
+
repo_dir_name = repo_url_to_dirname(repo_url)
|
|
39
67
|
repo_path = store_dir / repo_dir_name
|
|
40
68
|
|
|
41
|
-
|
|
69
|
+
# Get old commit from any skill in this repo
|
|
70
|
+
old_commit = None
|
|
71
|
+
for s in lock.skills:
|
|
72
|
+
if s.repo == repo_url:
|
|
73
|
+
old_commit = s.commit
|
|
74
|
+
break
|
|
42
75
|
|
|
43
76
|
# Pull latest
|
|
44
|
-
click.echo(f'Pulling {
|
|
45
|
-
clone_or_pull(
|
|
77
|
+
click.echo(f'Pulling {click.style(repo_url, fg="cyan")}...')
|
|
78
|
+
clone_or_pull(repo_url, repo_path)
|
|
46
79
|
new_commit = get_head_commit(repo_path)
|
|
47
80
|
|
|
48
81
|
if old_commit == new_commit:
|
|
49
|
-
click.echo(f' Already up to date ({old_commit[:8]})')
|
|
82
|
+
click.echo(click.style(f' ✔ Already up to date ({old_commit[:8]})', fg='green'))
|
|
50
83
|
return
|
|
51
84
|
|
|
52
85
|
# Show changes
|
|
53
86
|
log = get_log_between(repo_path, old_commit, new_commit)
|
|
54
|
-
click.echo(f' Updated {old_commit[:8]}
|
|
87
|
+
click.echo(f' Updated {click.style(old_commit[:8], fg="red")} → {click.style(new_commit[:8], fg="green")}')
|
|
55
88
|
if log:
|
|
56
89
|
for line in log.splitlines():
|
|
57
|
-
click.echo(f' {line}')
|
|
90
|
+
click.echo(click.style(f' {line}', dim=True))
|
|
58
91
|
|
|
59
92
|
# Re-detect and re-link
|
|
60
93
|
detected = detect_skills(repo_path)
|
|
61
94
|
repo_config = None
|
|
62
95
|
for c in config.packages:
|
|
63
|
-
if c.repo ==
|
|
96
|
+
if c.repo == repo_url:
|
|
64
97
|
repo_config = c
|
|
65
98
|
break
|
|
66
99
|
|
|
67
100
|
if repo_config is None:
|
|
68
|
-
click.echo(f" Error: repo '{
|
|
101
|
+
click.echo(f" Error: repo '{repo_url}' not found in config. Cannot determine target agents.")
|
|
69
102
|
raise SystemExit(1)
|
|
70
103
|
|
|
71
104
|
target_agents = resolve_target_agents(
|
|
@@ -76,7 +109,7 @@ def run_update(
|
|
|
76
109
|
# Update all skills from this repo in the lock
|
|
77
110
|
stale_indices = []
|
|
78
111
|
for i, locked_skill in enumerate(lock.skills):
|
|
79
|
-
if locked_skill.repo !=
|
|
112
|
+
if locked_skill.repo != repo_url:
|
|
80
113
|
continue
|
|
81
114
|
# Find matching detected skill
|
|
82
115
|
matching = [d for d in detected if d.name == locked_skill.name]
|
|
@@ -95,7 +128,7 @@ def run_update(
|
|
|
95
128
|
skill = matching[0]
|
|
96
129
|
linked_paths = []
|
|
97
130
|
for agent_name, agent_dir in target_agents.items():
|
|
98
|
-
link, _status = link_skill(skill.path, skill.name, agent_dir, agent_name=agent_name)
|
|
131
|
+
link, _status = link_skill(skill.path, skill.name, agent_dir, force=True, agent_name=agent_name)
|
|
99
132
|
linked_paths.append(compact_path(str(link)))
|
|
100
133
|
|
|
101
134
|
lock.skills[i] = InstalledSkill(
|
|
@@ -109,6 +142,3 @@ def run_update(
|
|
|
109
142
|
# Remove stale entries (iterate in reverse to preserve indices)
|
|
110
143
|
for i in reversed(stale_indices):
|
|
111
144
|
lock.skills.pop(i)
|
|
112
|
-
|
|
113
|
-
save_lock(lock, lock_path)
|
|
114
|
-
click.echo(f'Lock file updated.')
|
|
@@ -6,26 +6,26 @@ 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
19
|
def detect_skills(repo_path: Path) -> list[DetectedSkill]:
|
|
20
20
|
"""Detect skills in a cloned repo by walking for SKILL.md files."""
|
|
21
21
|
# Case 1: Root has SKILL.md → singleton skill
|
|
22
|
-
root_skill = repo_path /
|
|
22
|
+
root_skill = repo_path / 'SKILL.md'
|
|
23
23
|
if root_skill.exists():
|
|
24
24
|
name = parse_skill_name(root_skill)
|
|
25
|
-
return [DetectedSkill(name=name, path=repo_path, relative_path=
|
|
25
|
+
return [DetectedSkill(name=name, path=repo_path, relative_path='.')]
|
|
26
26
|
|
|
27
27
|
# Determine walk root
|
|
28
|
-
skills_dir = repo_path /
|
|
28
|
+
skills_dir = repo_path / 'skills'
|
|
29
29
|
walk_root = skills_dir if skills_dir.is_dir() else repo_path
|
|
30
30
|
|
|
31
31
|
return _walk_for_skills(walk_root, repo_path)
|
|
@@ -37,9 +37,9 @@ def _walk_for_skills(walk_root: Path, repo_path: Path) -> list[DetectedSkill]:
|
|
|
37
37
|
for child in sorted(walk_root.iterdir()):
|
|
38
38
|
if not child.is_dir() or child.is_symlink():
|
|
39
39
|
continue
|
|
40
|
-
if child.name.
|
|
40
|
+
if child.name == '.git':
|
|
41
41
|
continue
|
|
42
|
-
skill_md = child /
|
|
42
|
+
skill_md = child / 'SKILL.md'
|
|
43
43
|
if skill_md.exists():
|
|
44
44
|
name = parse_skill_name(skill_md)
|
|
45
45
|
relative = str(child.relative_to(repo_path))
|
|
@@ -51,7 +51,7 @@ def clone_or_pull(repo_url: str, dest: Path) -> None:
|
|
|
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", repo_url, str(dest)])
|
|
54
|
+
run_cmd(["git", "clone", "--filter=blob:none", repo_url, str(dest)])
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def get_head_commit(repo_path: Path) -> str:
|