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.
Files changed (52) hide show
  1. skm_cli-0.2.1/CLAUDE.md +1 -0
  2. {skm_cli-0.2.0 → skm_cli-0.2.1}/PKG-INFO +42 -1
  3. {skm_cli-0.2.0 → skm_cli-0.2.1}/README.md +41 -0
  4. {skm_cli-0.2.0 → skm_cli-0.2.1}/pyproject.toml +1 -1
  5. skm_cli-0.2.1/src/skm/clonefile.py +106 -0
  6. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/update.py +1 -1
  7. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/detect.py +10 -10
  8. skm_cli-0.2.1/src/skm/linker.py +195 -0
  9. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_cli_e2e.py +28 -0
  10. skm_cli-0.2.1/tests/test_clonefile.py +178 -0
  11. skm_cli-0.2.1/tests/test_detect.py +125 -0
  12. skm_cli-0.2.1/tests/test_linker.py +280 -0
  13. {skm_cli-0.2.0 → skm_cli-0.2.1}/uv.lock +1 -1
  14. skm_cli-0.2.0/src/skm/linker.py +0 -116
  15. skm_cli-0.2.0/tests/test_detect.py +0 -81
  16. skm_cli-0.2.0/tests/test_linker.py +0 -142
  17. {skm_cli-0.2.0 → skm_cli-0.2.1}/.gitignore +0 -0
  18. {skm_cli-0.2.0 → skm_cli-0.2.1}/AGENTS.md +0 -0
  19. {skm_cli-0.2.0 → skm_cli-0.2.1}/draft.md +0 -0
  20. {skm_cli-0.2.0 → skm_cli-0.2.1}/images/skm-install.png +0 -0
  21. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
  22. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
  23. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
  24. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
  25. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
  26. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
  27. {skm_cli-0.2.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
  28. {skm_cli-0.2.0 → skm_cli-0.2.1}/link-skill +0 -0
  29. {skm_cli-0.2.0 → skm_cli-0.2.1}/skills.example.yaml +0 -0
  30. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/__init__.py +0 -0
  31. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/cli.py +0 -0
  32. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/__init__.py +0 -0
  33. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/check_updates.py +0 -0
  34. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/install.py +0 -0
  35. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/list_cmd.py +0 -0
  36. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/remove.py +0 -0
  37. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/commands/view.py +0 -0
  38. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/config.py +0 -0
  39. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/git.py +0 -0
  40. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/lock.py +0 -0
  41. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/py.typed +0 -0
  42. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/tui.py +0 -0
  43. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/types.py +0 -0
  44. {skm_cli-0.2.0 → skm_cli-0.2.1}/src/skm/utils.py +0 -0
  45. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_config.py +0 -0
  46. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_git.py +0 -0
  47. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_install.py +0 -0
  48. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_install_from_source.py +0 -0
  49. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_list_cmd.py +0 -0
  50. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_local_path.py +0 -0
  51. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_lock.py +0 -0
  52. {skm_cli-0.2.0 → skm_cli-0.2.1}/tests/test_types.py +0 -0
@@ -0,0 +1 @@
1
+ AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skm-cli
3
- Version: 0.2.0
3
+ Version: 0.2.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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skm-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Skill Manager - manage agent skills from GitHub repos"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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="utf-8")
10
- match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
9
+ text = skill_md_path.read_text(encoding='utf-8')
10
+ match = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
11
11
  if not match:
12
- raise ValueError(f"No frontmatter found in {skill_md_path}")
12
+ raise ValueError(f'No frontmatter found in {skill_md_path}')
13
13
  for line in match.group(1).splitlines():
14
- if line.startswith("name:"):
15
- return line.split(":", 1)[1].strip()
14
+ if line.startswith('name:'):
15
+ return line.split(':', 1)[1].strip().strip('\'"')
16
16
  raise ValueError(f"No 'name' field in frontmatter of {skill_md_path}")
17
17
 
18
18
 
19
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 / "SKILL.md"
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 / "skills"
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.startswith("."):
40
+ if child.name == '.git':
41
41
  continue
42
- skill_md = child / "SKILL.md"
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'