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.
Files changed (52) hide show
  1. skm_cli-0.2.1/CLAUDE.md +1 -0
  2. {skm_cli-0.1.0 → skm_cli-0.2.1}/PKG-INFO +49 -1
  3. {skm_cli-0.1.0 → skm_cli-0.2.1}/README.md +48 -0
  4. {skm_cli-0.1.0 → skm_cli-0.2.1}/pyproject.toml +1 -1
  5. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/cli.py +51 -4
  6. skm_cli-0.2.1/src/skm/clonefile.py +106 -0
  7. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/check_updates.py +10 -7
  8. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/update.py +57 -27
  9. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/detect.py +10 -10
  10. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/git.py +1 -1
  11. skm_cli-0.2.1/src/skm/linker.py +195 -0
  12. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_cli_e2e.py +28 -0
  13. skm_cli-0.2.1/tests/test_clonefile.py +178 -0
  14. skm_cli-0.2.1/tests/test_detect.py +125 -0
  15. skm_cli-0.2.1/tests/test_linker.py +280 -0
  16. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_local_path.py +1 -1
  17. {skm_cli-0.1.0 → skm_cli-0.2.1}/uv.lock +2 -2
  18. skm_cli-0.1.0/src/skm/linker.py +0 -116
  19. skm_cli-0.1.0/tests/test_detect.py +0 -81
  20. skm_cli-0.1.0/tests/test_linker.py +0 -142
  21. {skm_cli-0.1.0 → skm_cli-0.2.1}/.gitignore +0 -0
  22. {skm_cli-0.1.0 → skm_cli-0.2.1}/AGENTS.md +0 -0
  23. {skm_cli-0.1.0 → skm_cli-0.2.1}/draft.md +0 -0
  24. {skm_cli-0.1.0 → skm_cli-0.2.1}/images/skm-install.png +0 -0
  25. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/plans/2026-03-09-skm-skill-manager.md +0 -0
  26. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-09-refactor-config-structure.md +0 -0
  27. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-add-local-path-support.md +0 -0
  28. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-extend-skm-install-direct-source.md +0 -0
  29. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-handle-existing-skill-dir-override.md +0 -0
  30. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-implement-skm-view-command.md +0 -0
  31. {skm_cli-0.1.0 → skm_cli-0.2.1}/kb/sessions/2026-03-10-install-idempotent-sync.md +0 -0
  32. {skm_cli-0.1.0 → skm_cli-0.2.1}/link-skill +0 -0
  33. {skm_cli-0.1.0 → skm_cli-0.2.1}/skills.example.yaml +0 -0
  34. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/__init__.py +0 -0
  35. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/__init__.py +0 -0
  36. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/install.py +0 -0
  37. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/list_cmd.py +0 -0
  38. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/remove.py +0 -0
  39. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/commands/view.py +0 -0
  40. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/config.py +0 -0
  41. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/lock.py +0 -0
  42. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/py.typed +0 -0
  43. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/tui.py +0 -0
  44. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/types.py +0 -0
  45. {skm_cli-0.1.0 → skm_cli-0.2.1}/src/skm/utils.py +0 -0
  46. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_config.py +0 -0
  47. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_git.py +0 -0
  48. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_install.py +0 -0
  49. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_install_from_source.py +0 -0
  50. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_list_cmd.py +0 -0
  51. {skm_cli-0.1.0 → skm_cli-0.2.1}/tests/test_lock.py +0 -0
  52. {skm_cli-0.1.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.1.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
@@ -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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skm-cli"
3
- version = "0.1.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 = [
@@ -294,17 +294,22 @@ def check_updates(ctx):
294
294
 
295
295
 
296
296
  @cli.command()
297
- @click.argument('skill_name')
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, skill_name: str):
300
- """Update a specific skill."""
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
- skill_name=skill_name,
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(f" Up to date")
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]} -> {remote_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("\nAll skills are up to date.")
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
- skill_name: str,
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
- # Find skill in lock
24
- old_skill = None
25
- for s in lock.skills:
26
- if s.name == skill_name:
27
- old_skill = s
28
- break
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
- if old_skill is None:
31
- click.echo(f"Skill '{skill_name}' is not installed.")
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
- if old_skill.repo is None:
35
- click.echo(f"Skill '{skill_name}' is from a local path, skipping update.")
36
- return
55
+ save_lock(lock, lock_path)
56
+ click.echo('Lock file updated.')
37
57
 
38
- repo_dir_name = repo_url_to_dirname(old_skill.repo)
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
- old_commit = old_skill.commit
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 {old_skill.repo}...')
45
- clone_or_pull(old_skill.repo, repo_path)
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]} -> {new_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 == old_skill.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 '{old_skill.repo}' not found in config. Cannot determine target agents.")
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 != old_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="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))
@@ -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: