skm-cli 0.2.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.2.0 → skm_cli-0.2.1}/PKG-INFO +42 -1
- {skm_cli-0.2.0 → skm_cli-0.2.1}/README.md +41 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/pyproject.toml +1 -1
- skm_cli-0.2.1/src/skm/clonefile.py +106 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/update.py +1 -1
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/detect.py +10 -10
- skm_cli-0.2.1/src/skm/linker.py +195 -0
- {skm_cli-0.2.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.2.0 → skm_cli-0.2.1}/uv.lock +1 -1
- 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 → skm_cli-0.2.1}/.gitignore +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/AGENTS.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/draft.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/images/skm-install.png +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/link-skill +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/skills.example.yaml +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/__init__.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/cli.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/__init__.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/check_updates.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/install.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/list_cmd.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/remove.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/view.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/config.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/git.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/lock.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/py.typed +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/tui.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/types.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/utils.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_config.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_git.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_install.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_install_from_source.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_list_cmd.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_local_path.py +0 -0
- {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_lock.py +0 -0
- {skm_cli-0.2.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.2.
|
|
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
|
|
@@ -141,6 +141,47 @@ Skills are symlinked into these directories by default:
|
|
|
141
141
|
| `codex` | `~/.codex/skills/` |
|
|
142
142
|
| `openclaw` | `~/.openclaw/skills/` |
|
|
143
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
|
+
|
|
144
185
|
## CLI Path Overrides
|
|
145
186
|
|
|
146
187
|
Override default paths for testing or custom setups:
|
|
@@ -130,6 +130,47 @@ Skills are symlinked into these directories by default:
|
|
|
130
130
|
| `codex` | `~/.codex/skills/` |
|
|
131
131
|
| `openclaw` | `~/.openclaw/skills/` |
|
|
132
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
|
+
|
|
133
174
|
## CLI Path Overrides
|
|
134
175
|
|
|
135
176
|
Override default paths for testing or custom setups:
|
|
@@ -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
|
|
@@ -128,7 +128,7 @@ def _update_repo(
|
|
|
128
128
|
skill = matching[0]
|
|
129
129
|
linked_paths = []
|
|
130
130
|
for agent_name, agent_dir in target_agents.items():
|
|
131
|
-
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)
|
|
132
132
|
linked_paths.append(compact_path(str(link)))
|
|
133
133
|
|
|
134
134
|
lock.skills[i] = InstalledSkill(
|
|
@@ -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))
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import filecmp
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from skm.clonefile import clone_file, is_reflink_unsupported, reflink_supported
|
|
8
|
+
from skm.types import AGENT_OPTIONS, AgentsConfig
|
|
9
|
+
|
|
10
|
+
MaterializationMode = Literal['hardlink', 'reflink', 'copy']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_agent_option(agent_name: str, option: str, default=None):
|
|
14
|
+
"""Look up a per-agent option from AGENT_OPTIONS."""
|
|
15
|
+
return AGENT_OPTIONS.get(agent_name, {}).get(option, default)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_target_agents(
|
|
19
|
+
agents_config: AgentsConfig | None,
|
|
20
|
+
known_agents: dict[str, str],
|
|
21
|
+
) -> dict[str, str]:
|
|
22
|
+
"""Determine which agents to install to based on includes/excludes."""
|
|
23
|
+
if agents_config is None:
|
|
24
|
+
return dict(known_agents)
|
|
25
|
+
|
|
26
|
+
if agents_config.includes is not None:
|
|
27
|
+
return {k: v for k, v in known_agents.items() if k in agents_config.includes}
|
|
28
|
+
|
|
29
|
+
if agents_config.excludes is not None:
|
|
30
|
+
return {k: v for k, v in known_agents.items() if k not in agents_config.excludes}
|
|
31
|
+
|
|
32
|
+
return dict(known_agents)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clone_file_reflink(src: Path, dst: Path) -> None:
|
|
36
|
+
"""Clone a file using the platform's COW mechanism (see skm.clonefile)."""
|
|
37
|
+
clone_file(src, dst)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _copy_file(src: Path, dst: Path) -> None:
|
|
41
|
+
"""Copy a file with metadata."""
|
|
42
|
+
shutil.copy2(src, dst)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_materialized_entries(path: Path) -> dict[str, Path]:
|
|
46
|
+
"""Return managed candidate entries, excluding hidden files and symlinks."""
|
|
47
|
+
return {item.name: item for item in path.iterdir() if not item.name.startswith('.') and not item.is_symlink()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _supports_copy_fallback(exc: OSError) -> bool:
|
|
51
|
+
"""Return True when a reflink failure should fall back to plain copy."""
|
|
52
|
+
return is_reflink_unsupported(exc)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _materialize_file(
|
|
56
|
+
src: Path,
|
|
57
|
+
dst: Path,
|
|
58
|
+
mode: MaterializationMode,
|
|
59
|
+
) -> MaterializationMode:
|
|
60
|
+
"""Materialize a file and return the mode to keep using."""
|
|
61
|
+
if mode == 'hardlink':
|
|
62
|
+
os.link(src, dst)
|
|
63
|
+
return 'hardlink'
|
|
64
|
+
|
|
65
|
+
if mode == 'copy':
|
|
66
|
+
_copy_file(src, dst)
|
|
67
|
+
return 'copy'
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
_clone_file_reflink(src, dst)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
if not _supports_copy_fallback(exc):
|
|
73
|
+
raise
|
|
74
|
+
_copy_file(src, dst)
|
|
75
|
+
return 'copy'
|
|
76
|
+
return 'reflink'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _materialize_tree(
|
|
80
|
+
src: Path,
|
|
81
|
+
dst: Path,
|
|
82
|
+
mode: MaterializationMode,
|
|
83
|
+
) -> MaterializationMode:
|
|
84
|
+
"""Recreate directory structure from src at dst using a materialization mode."""
|
|
85
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
current_mode = mode
|
|
87
|
+
for item in src.iterdir():
|
|
88
|
+
if item.name.startswith('.') or item.is_symlink():
|
|
89
|
+
continue
|
|
90
|
+
target = dst / item.name
|
|
91
|
+
if item.is_dir():
|
|
92
|
+
current_mode = _materialize_tree(item, target, current_mode)
|
|
93
|
+
else:
|
|
94
|
+
if not target.exists():
|
|
95
|
+
current_mode = _materialize_file(item, target, current_mode)
|
|
96
|
+
return current_mode
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_managed_materialized_dir(link_path: Path, skill_src: Path) -> bool:
|
|
100
|
+
"""Check whether link_path looks like a managed hardlink/reflink materialized copy."""
|
|
101
|
+
if not link_path.is_dir() or link_path.is_symlink():
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
source_entries = _get_materialized_entries(skill_src)
|
|
105
|
+
target_entries = _get_materialized_entries(link_path)
|
|
106
|
+
if not target_entries.keys() <= source_entries.keys():
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
for name, target_item in target_entries.items():
|
|
110
|
+
source_item = source_entries[name]
|
|
111
|
+
if source_item.is_dir():
|
|
112
|
+
if not _is_managed_materialized_dir(target_item, source_item):
|
|
113
|
+
return False
|
|
114
|
+
continue
|
|
115
|
+
if not target_item.is_file():
|
|
116
|
+
return False
|
|
117
|
+
if source_item.stat().st_ino == target_item.stat().st_ino:
|
|
118
|
+
continue
|
|
119
|
+
if not filecmp.cmp(source_item, target_item, shallow=False):
|
|
120
|
+
return False
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _select_materialization_mode(skill_src: Path, target_dir: Path) -> MaterializationMode:
|
|
125
|
+
"""Pick hardlink on the same device, otherwise use reflink or plain copy."""
|
|
126
|
+
src_dev = skill_src.stat().st_dev
|
|
127
|
+
dst_dev = target_dir.stat().st_dev
|
|
128
|
+
if src_dev == dst_dev:
|
|
129
|
+
return 'hardlink'
|
|
130
|
+
|
|
131
|
+
if not reflink_supported():
|
|
132
|
+
return 'copy'
|
|
133
|
+
return 'reflink'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def link_skill(
|
|
137
|
+
skill_src: Path, skill_name: str, agent_skills_dir: str, force: bool = False, agent_name: str = ''
|
|
138
|
+
) -> tuple[Path, str]:
|
|
139
|
+
"""Create a symlink (or hardlink tree) from agent_skills_dir/skill_name -> skill_src.
|
|
140
|
+
|
|
141
|
+
Returns (link_path, status) where status is "new", "exists", or "replaced".
|
|
142
|
+
"""
|
|
143
|
+
use_hardlink = _get_agent_option(agent_name, 'use_hardlink', False)
|
|
144
|
+
target_dir = Path(agent_skills_dir)
|
|
145
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
link_path = target_dir / skill_name
|
|
147
|
+
|
|
148
|
+
if use_hardlink:
|
|
149
|
+
# Materialized mode: prefer hardlink, fall back to reflink when devices differ.
|
|
150
|
+
mode = _select_materialization_mode(skill_src, target_dir)
|
|
151
|
+
if link_path.exists() and not link_path.is_symlink():
|
|
152
|
+
if _is_managed_materialized_dir(link_path, skill_src):
|
|
153
|
+
# Already managed, refresh to pick up any changes.
|
|
154
|
+
_materialize_tree(skill_src, link_path, mode)
|
|
155
|
+
return (link_path, 'exists')
|
|
156
|
+
if not force:
|
|
157
|
+
raise FileExistsError(f'{link_path} exists and is not a managed copy')
|
|
158
|
+
shutil.rmtree(link_path)
|
|
159
|
+
_materialize_tree(skill_src, link_path, mode)
|
|
160
|
+
return (link_path, 'replaced')
|
|
161
|
+
elif link_path.is_symlink():
|
|
162
|
+
# Switching from symlink to a materialized copy.
|
|
163
|
+
link_path.unlink()
|
|
164
|
+
|
|
165
|
+
_materialize_tree(skill_src, link_path, mode)
|
|
166
|
+
return (link_path, 'new')
|
|
167
|
+
|
|
168
|
+
# Symlink mode (default)
|
|
169
|
+
if link_path.is_symlink():
|
|
170
|
+
if link_path.resolve() == skill_src.resolve():
|
|
171
|
+
return (link_path, 'exists')
|
|
172
|
+
link_path.unlink()
|
|
173
|
+
link_path.symlink_to(skill_src)
|
|
174
|
+
return (link_path, 'replaced')
|
|
175
|
+
|
|
176
|
+
if link_path.exists():
|
|
177
|
+
if force:
|
|
178
|
+
if link_path.is_dir():
|
|
179
|
+
shutil.rmtree(link_path)
|
|
180
|
+
else:
|
|
181
|
+
link_path.unlink()
|
|
182
|
+
else:
|
|
183
|
+
raise FileExistsError(f'{link_path} exists and is not a symlink')
|
|
184
|
+
|
|
185
|
+
link_path.symlink_to(skill_src)
|
|
186
|
+
return (link_path, 'new')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def unlink_skill(skill_name: str, agent_skills_dir: str) -> None:
|
|
190
|
+
"""Remove symlink or hardlinked dir for a skill from an agent dir."""
|
|
191
|
+
link_path = Path(agent_skills_dir) / skill_name
|
|
192
|
+
if link_path.is_symlink():
|
|
193
|
+
link_path.unlink()
|
|
194
|
+
elif link_path.is_dir():
|
|
195
|
+
shutil.rmtree(link_path)
|
|
@@ -383,6 +383,34 @@ class TestUpdate:
|
|
|
383
383
|
# The commit should be 40 hex chars (full SHA)
|
|
384
384
|
assert len(old_commit) == 40
|
|
385
385
|
|
|
386
|
+
def test_update_removes_deleted_materialized_files(self, tmp_path):
|
|
387
|
+
repo = _make_skill_repo(tmp_path, 'repo-upd3', [{'name': 'upd-skill'}])
|
|
388
|
+
tracked_file = repo / 'skills' / 'upd-skill' / 'extra.md'
|
|
389
|
+
tracked_file.write_text('old content')
|
|
390
|
+
subprocess.run(['git', 'add', '.'], cwd=repo, capture_output=True, check=True)
|
|
391
|
+
subprocess.run(['git', 'commit', '-m', 'add extra'], cwd=repo, capture_output=True, check=True)
|
|
392
|
+
|
|
393
|
+
_write_config(tmp_path, [{'repo': str(repo)}])
|
|
394
|
+
|
|
395
|
+
runner = CliRunner()
|
|
396
|
+
install_result = runner.invoke(cli, [*_cli_args(tmp_path), 'install'])
|
|
397
|
+
assert install_result.exit_code == 0, install_result.output
|
|
398
|
+
|
|
399
|
+
standard_file = tmp_path / 'agents' / 'standard' / 'upd-skill' / 'extra.md'
|
|
400
|
+
openclaw_file = tmp_path / 'agents' / 'openclaw' / 'upd-skill' / 'extra.md'
|
|
401
|
+
assert standard_file.exists()
|
|
402
|
+
assert openclaw_file.exists()
|
|
403
|
+
|
|
404
|
+
tracked_file.unlink()
|
|
405
|
+
subprocess.run(['git', 'add', '-A'], cwd=repo, capture_output=True, check=True)
|
|
406
|
+
subprocess.run(['git', 'commit', '-m', 'remove extra'], cwd=repo, capture_output=True, check=True)
|
|
407
|
+
|
|
408
|
+
result = runner.invoke(cli, [*_cli_args(tmp_path), 'update', 'upd-skill'])
|
|
409
|
+
assert result.exit_code == 0, result.output
|
|
410
|
+
assert 'remove extra' in result.output
|
|
411
|
+
assert not standard_file.exists()
|
|
412
|
+
assert not openclaw_file.exists()
|
|
413
|
+
|
|
386
414
|
|
|
387
415
|
class TestCheckUpdates:
|
|
388
416
|
def test_check_updates_no_skills(self, tmp_path):
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Tests for skm.clonefile — platform-aware reflink/COW cloning."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from skm import clonefile
|
|
10
|
+
from skm.clonefile import (
|
|
11
|
+
clone_file,
|
|
12
|
+
is_reflink_unsupported,
|
|
13
|
+
reflink_supported,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_SYSTEM = platform.system()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- is_reflink_unsupported --------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_is_reflink_unsupported_recognises_enotsup():
|
|
23
|
+
assert is_reflink_unsupported(OSError(errno.ENOTSUP, 'not supported'))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_is_reflink_unsupported_rejects_eio():
|
|
27
|
+
assert not is_reflink_unsupported(OSError(errno.EIO, 'disk error'))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- reflink_supported -------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_reflink_supported_returns_bool():
|
|
34
|
+
result = reflink_supported()
|
|
35
|
+
assert isinstance(result, bool)
|
|
36
|
+
if _SYSTEM == 'Darwin':
|
|
37
|
+
# macOS with APFS should have clonefile available
|
|
38
|
+
assert result is True
|
|
39
|
+
elif _SYSTEM == 'Linux':
|
|
40
|
+
# Linux should have fcntl available
|
|
41
|
+
assert result is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_reflink_supported_false_on_unknown_platform(monkeypatch):
|
|
45
|
+
monkeypatch.setattr(clonefile, '_SYSTEM', 'FreeBSD')
|
|
46
|
+
assert reflink_supported() is False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- clone_file: unsupported platform ----------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_clone_file_raises_on_unsupported_platform(monkeypatch, tmp_path):
|
|
53
|
+
monkeypatch.setattr(clonefile, '_SYSTEM', 'FreeBSD')
|
|
54
|
+
src = tmp_path / 'src.txt'
|
|
55
|
+
dst = tmp_path / 'dst.txt'
|
|
56
|
+
src.write_text('hello')
|
|
57
|
+
with pytest.raises(OSError) as exc_info:
|
|
58
|
+
clone_file(src, dst)
|
|
59
|
+
assert is_reflink_unsupported(exc_info.value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- clone_file: Linux backend via monkeypatch --------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_clone_file_linux_delegates_to_ficlone_ioctl(monkeypatch, tmp_path):
|
|
66
|
+
"""Verify the Linux path calls fcntl.ioctl with FICLONE."""
|
|
67
|
+
calls = []
|
|
68
|
+
|
|
69
|
+
class FakeFcntl:
|
|
70
|
+
@staticmethod
|
|
71
|
+
def ioctl(dst_fd, request, src_fd):
|
|
72
|
+
calls.append((dst_fd, request, src_fd))
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(clonefile, '_SYSTEM', 'Linux')
|
|
75
|
+
monkeypatch.setattr(clonefile, '_fcntl', FakeFcntl)
|
|
76
|
+
|
|
77
|
+
src = tmp_path / 'src.txt'
|
|
78
|
+
dst = tmp_path / 'dst.txt'
|
|
79
|
+
src.write_text('hello')
|
|
80
|
+
|
|
81
|
+
clone_file(src, dst)
|
|
82
|
+
|
|
83
|
+
assert len(calls) == 1
|
|
84
|
+
_dst_fd, request, _src_fd = calls[0]
|
|
85
|
+
assert request == 0x40049409 # _FICLONE
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- clone_file: macOS backend via monkeypatch --------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_clone_file_darwin_delegates_to_clonefile_syscall(monkeypatch, tmp_path):
|
|
92
|
+
"""Verify the macOS path calls the clonefile C function."""
|
|
93
|
+
calls = []
|
|
94
|
+
|
|
95
|
+
def fake_clonefile(src_bytes, dst_bytes, flags):
|
|
96
|
+
calls.append((src_bytes, dst_bytes, flags))
|
|
97
|
+
# Simulate success by actually copying
|
|
98
|
+
shutil.copy2(src_bytes.decode(), dst_bytes.decode())
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
monkeypatch.setattr(clonefile, '_SYSTEM', 'Darwin')
|
|
102
|
+
monkeypatch.setattr(clonefile, '_clonefile_func', fake_clonefile)
|
|
103
|
+
|
|
104
|
+
src = tmp_path / 'src.txt'
|
|
105
|
+
dst = tmp_path / 'dst.txt'
|
|
106
|
+
src.write_text('hello')
|
|
107
|
+
|
|
108
|
+
clone_file(src, dst)
|
|
109
|
+
|
|
110
|
+
assert len(calls) == 1
|
|
111
|
+
assert calls[0][2] == 0 # flags
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_clone_file_darwin_raises_on_failure(monkeypatch, tmp_path):
|
|
115
|
+
"""Verify macOS clonefile errors are raised as OSError."""
|
|
116
|
+
import ctypes
|
|
117
|
+
|
|
118
|
+
def fake_clonefile(src_bytes, dst_bytes, flags):
|
|
119
|
+
ctypes.set_errno(errno.ENOTSUP)
|
|
120
|
+
return -1
|
|
121
|
+
|
|
122
|
+
monkeypatch.setattr(clonefile, '_SYSTEM', 'Darwin')
|
|
123
|
+
monkeypatch.setattr(clonefile, '_clonefile_func', fake_clonefile)
|
|
124
|
+
|
|
125
|
+
src = tmp_path / 'src.txt'
|
|
126
|
+
dst = tmp_path / 'dst.txt'
|
|
127
|
+
src.write_text('hello')
|
|
128
|
+
|
|
129
|
+
with pytest.raises(OSError) as exc_info:
|
|
130
|
+
clone_file(src, dst)
|
|
131
|
+
assert is_reflink_unsupported(exc_info.value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- Real clone_file on current platform -------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.skipif(not reflink_supported(), reason='reflink not available on this platform')
|
|
138
|
+
def test_clone_file_real(tmp_path):
|
|
139
|
+
"""Attempt a real reflink clone on the current filesystem.
|
|
140
|
+
|
|
141
|
+
On APFS (macOS) this should succeed and produce a COW clone.
|
|
142
|
+
On Linux this depends on the filesystem (Btrfs/XFS succeed, ext4 fails).
|
|
143
|
+
If the filesystem doesn't support it, the error should be recognised
|
|
144
|
+
by is_reflink_unsupported.
|
|
145
|
+
"""
|
|
146
|
+
src = tmp_path / 'original.txt'
|
|
147
|
+
src.write_text('content for reflink test')
|
|
148
|
+
dst = tmp_path / 'cloned.txt'
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
clone_file(src, dst)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
# Filesystem doesn't support reflink — that's OK, just verify
|
|
154
|
+
# we correctly identify it as "unsupported" rather than a real error.
|
|
155
|
+
assert is_reflink_unsupported(exc), f'unexpected OSError: {exc}'
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
assert dst.read_text() == 'content for reflink test'
|
|
159
|
+
# On a COW filesystem, inodes should differ (it's a clone, not a hardlink)
|
|
160
|
+
assert src.stat().st_ino != dst.stat().st_ino
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.skipif(not reflink_supported(), reason='reflink not available on this platform')
|
|
164
|
+
def test_clone_file_real_dst_must_not_exist(tmp_path):
|
|
165
|
+
"""clonefile(2) on macOS refuses to overwrite; FICLONE on Linux creates dst via open('wb')."""
|
|
166
|
+
src = tmp_path / 'src.txt'
|
|
167
|
+
src.write_text('hello')
|
|
168
|
+
dst = tmp_path / 'dst.txt'
|
|
169
|
+
dst.write_text('already here')
|
|
170
|
+
|
|
171
|
+
if _SYSTEM == 'Darwin':
|
|
172
|
+
# macOS clonefile fails with EEXIST
|
|
173
|
+
with pytest.raises(OSError):
|
|
174
|
+
clone_file(src, dst)
|
|
175
|
+
else:
|
|
176
|
+
# Linux FICLONE opens dst with 'wb' (truncates), so this succeeds
|
|
177
|
+
clone_file(src, dst)
|
|
178
|
+
assert dst.read_text() == 'hello'
|