skeel 0.1.0__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.
- skeel-0.1.0/.gitignore +7 -0
- skeel-0.1.0/LICENSE +21 -0
- skeel-0.1.0/PKG-INFO +91 -0
- skeel-0.1.0/README.md +67 -0
- skeel-0.1.0/examples/skills.yaml +18 -0
- skeel-0.1.0/pyproject.toml +113 -0
- skeel-0.1.0/src/skeel/__init__.py +3 -0
- skeel-0.1.0/src/skeel/backends.py +132 -0
- skeel-0.1.0/src/skeel/cli.py +147 -0
- skeel-0.1.0/src/skeel/manifest.py +117 -0
- skeel-0.1.0/src/skeel/py.typed +0 -0
- skeel-0.1.0/tests/test_backends.py +59 -0
- skeel-0.1.0/tests/test_manifest.py +47 -0
skeel-0.1.0/.gitignore
ADDED
skeel-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthias Vallentin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
skeel-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skeel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative agent skill manager with pluggable backends.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mavam/skeel
|
|
6
|
+
Project-URL: Repository, https://github.com/mavam/skeel
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/mavam/skeel/issues
|
|
8
|
+
Author: Matthias Vallentin
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-skills,agents,cli,github-cli,skills
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: <3.15,>=3.14
|
|
22
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# 🛠️ skeel
|
|
26
|
+
|
|
27
|
+
Declarative agent skill management.
|
|
28
|
+
|
|
29
|
+
**skeel** reads a desired-state manifest and applies it through a backend.
|
|
30
|
+
|
|
31
|
+
The first backend is `gh skill` from GitHub CLI.
|
|
32
|
+
|
|
33
|
+
## ✨ Features
|
|
34
|
+
|
|
35
|
+
- **Desired state**: declare skills, sources, shared install directory, and
|
|
36
|
+
target agents in one YAML file
|
|
37
|
+
- **Plan and diff**: preview commands and compare managed skills against what's
|
|
38
|
+
installed locally
|
|
39
|
+
- **Apply and update**: install missing skills and update gh-managed skills
|
|
40
|
+
- **Agent links**: installs once into a shared directory and links skills into
|
|
41
|
+
agent-specific locations such as Claude Code
|
|
42
|
+
|
|
43
|
+
## 🚀 Quickstart
|
|
44
|
+
|
|
45
|
+
Run `skeel` directly with `uvx`:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
uvx skeel --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## ⚙️ Manifest
|
|
52
|
+
|
|
53
|
+
Default path: `~/.agents/.skill.yaml`
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
version: 1
|
|
57
|
+
shared_dir: ~/.agents/skills
|
|
58
|
+
agents:
|
|
59
|
+
- universal
|
|
60
|
+
- claude-code
|
|
61
|
+
sources:
|
|
62
|
+
- source: openclaw/gogcli
|
|
63
|
+
allow_hidden_dirs: true
|
|
64
|
+
skills:
|
|
65
|
+
- gog
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## ✨ Usage
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uvx skeel plan # print commands
|
|
72
|
+
uvx skeel diff # compare desired vs installed skills
|
|
73
|
+
uvx skeel apply # install desired state
|
|
74
|
+
uvx skeel update # update installed gh-managed skills
|
|
75
|
+
uvx skeel path # print manifest path
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 🧰 Backend policy
|
|
79
|
+
|
|
80
|
+
`skeel` installs each skill into the shared directory with:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
gh skill install <repo> <skill> --dir ~/.agents/skills --force
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For `claude-code`, it creates symlinks from `~/.claude/skills/<skill>` to the
|
|
87
|
+
shared skill directory.
|
|
88
|
+
|
|
89
|
+
## 📄 License
|
|
90
|
+
|
|
91
|
+
[MIT](LICENSE)
|
skeel-0.1.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# 🛠️ skeel
|
|
2
|
+
|
|
3
|
+
Declarative agent skill management.
|
|
4
|
+
|
|
5
|
+
**skeel** reads a desired-state manifest and applies it through a backend.
|
|
6
|
+
|
|
7
|
+
The first backend is `gh skill` from GitHub CLI.
|
|
8
|
+
|
|
9
|
+
## ✨ Features
|
|
10
|
+
|
|
11
|
+
- **Desired state**: declare skills, sources, shared install directory, and
|
|
12
|
+
target agents in one YAML file
|
|
13
|
+
- **Plan and diff**: preview commands and compare managed skills against what's
|
|
14
|
+
installed locally
|
|
15
|
+
- **Apply and update**: install missing skills and update gh-managed skills
|
|
16
|
+
- **Agent links**: installs once into a shared directory and links skills into
|
|
17
|
+
agent-specific locations such as Claude Code
|
|
18
|
+
|
|
19
|
+
## 🚀 Quickstart
|
|
20
|
+
|
|
21
|
+
Run `skeel` directly with `uvx`:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uvx skeel --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## ⚙️ Manifest
|
|
28
|
+
|
|
29
|
+
Default path: `~/.agents/.skill.yaml`
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
version: 1
|
|
33
|
+
shared_dir: ~/.agents/skills
|
|
34
|
+
agents:
|
|
35
|
+
- universal
|
|
36
|
+
- claude-code
|
|
37
|
+
sources:
|
|
38
|
+
- source: openclaw/gogcli
|
|
39
|
+
allow_hidden_dirs: true
|
|
40
|
+
skills:
|
|
41
|
+
- gog
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## ✨ Usage
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
uvx skeel plan # print commands
|
|
48
|
+
uvx skeel diff # compare desired vs installed skills
|
|
49
|
+
uvx skeel apply # install desired state
|
|
50
|
+
uvx skeel update # update installed gh-managed skills
|
|
51
|
+
uvx skeel path # print manifest path
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 🧰 Backend policy
|
|
55
|
+
|
|
56
|
+
`skeel` installs each skill into the shared directory with:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
gh skill install <repo> <skill> --dir ~/.agents/skills --force
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For `claude-code`, it creates symlinks from `~/.claude/skills/<skill>` to the
|
|
63
|
+
shared skill directory.
|
|
64
|
+
|
|
65
|
+
## 📄 License
|
|
66
|
+
|
|
67
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
shared_dir: ~/.agents/skills
|
|
3
|
+
agents:
|
|
4
|
+
- universal
|
|
5
|
+
- claude-code
|
|
6
|
+
sources:
|
|
7
|
+
- source: mavam/skills
|
|
8
|
+
private: true
|
|
9
|
+
skills:
|
|
10
|
+
- mavam
|
|
11
|
+
- source: openclaw/gogcli
|
|
12
|
+
allow_hidden_dirs: true
|
|
13
|
+
skills:
|
|
14
|
+
- gog
|
|
15
|
+
- source: anthropics/skills
|
|
16
|
+
skills:
|
|
17
|
+
- frontend-design
|
|
18
|
+
- skill-creator
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "skeel"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Declarative agent skill manager with pluggable backends."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14,<3.15"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Matthias Vallentin" }]
|
|
13
|
+
keywords = ["agent-skills", "agents", "cli", "github-cli", "skills"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Software Development :: Version Control",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["PyYAML>=6.0.2"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/mavam/skeel"
|
|
29
|
+
Repository = "https://github.com/mavam/skeel"
|
|
30
|
+
"Bug Tracker" = "https://github.com/mavam/skeel/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
skeel = "skeel.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/skeel"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = [
|
|
40
|
+
"/src/**/*",
|
|
41
|
+
"/tests",
|
|
42
|
+
"/examples",
|
|
43
|
+
"/README.md",
|
|
44
|
+
"/LICENSE",
|
|
45
|
+
"/pyproject.toml",
|
|
46
|
+
]
|
|
47
|
+
exclude = [
|
|
48
|
+
"*.pyc",
|
|
49
|
+
"__pycache__",
|
|
50
|
+
".pytest_cache",
|
|
51
|
+
".mypy_cache",
|
|
52
|
+
".ruff_cache",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = [
|
|
57
|
+
"coverage[toml]>=7.6.0",
|
|
58
|
+
"lefthook>=2.1.6",
|
|
59
|
+
"mypy>=1.11.0",
|
|
60
|
+
"pytest>=8.0",
|
|
61
|
+
"pytest-cov>=5.0.0",
|
|
62
|
+
"ruff>=0.8.0",
|
|
63
|
+
"tenzir-ship>=1.8.0",
|
|
64
|
+
"types-PyYAML>=6.0.12.20250915",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
target-version = "py314"
|
|
69
|
+
line-length = 100
|
|
70
|
+
src = ["src"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
74
|
+
|
|
75
|
+
[tool.ruff.format]
|
|
76
|
+
quote-style = "double"
|
|
77
|
+
indent-style = "space"
|
|
78
|
+
line-ending = "lf"
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
minversion = "8.0"
|
|
82
|
+
addopts = "-ra --strict-markers"
|
|
83
|
+
testpaths = ["tests"]
|
|
84
|
+
python_files = ["test_*.py", "*_test.py"]
|
|
85
|
+
pythonpath = ["src"]
|
|
86
|
+
|
|
87
|
+
[tool.mypy]
|
|
88
|
+
files = ["src"]
|
|
89
|
+
python_version = "3.14"
|
|
90
|
+
warn_return_any = true
|
|
91
|
+
warn_unused_configs = true
|
|
92
|
+
disallow_untyped_defs = true
|
|
93
|
+
disallow_incomplete_defs = true
|
|
94
|
+
check_untyped_defs = true
|
|
95
|
+
disallow_untyped_decorators = true
|
|
96
|
+
no_implicit_optional = true
|
|
97
|
+
warn_redundant_casts = true
|
|
98
|
+
warn_unused_ignores = true
|
|
99
|
+
warn_no_return = true
|
|
100
|
+
warn_unreachable = true
|
|
101
|
+
strict_equality = true
|
|
102
|
+
|
|
103
|
+
[tool.coverage.run]
|
|
104
|
+
source = ["src/skeel"]
|
|
105
|
+
branch = true
|
|
106
|
+
omit = [
|
|
107
|
+
"*/tests/*",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.coverage.report]
|
|
111
|
+
fail_under = 80
|
|
112
|
+
show_missing = true
|
|
113
|
+
skip_covered = true
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from .manifest import Manifest, SkillSpec, SourceSpec
|
|
11
|
+
|
|
12
|
+
Command = list[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class InstallStep:
|
|
17
|
+
label: str
|
|
18
|
+
command: Command
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Backend(Protocol):
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
def install_steps(
|
|
25
|
+
self,
|
|
26
|
+
manifest: Manifest,
|
|
27
|
+
source: SourceSpec,
|
|
28
|
+
skill: SkillSpec,
|
|
29
|
+
) -> list[InstallStep]: ...
|
|
30
|
+
|
|
31
|
+
def update_steps(self, manifest: Manifest) -> list[InstallStep]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def quote_command(command: Command) -> str:
|
|
35
|
+
return " ".join(shlex.quote(part) for part in command)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Runner:
|
|
39
|
+
def __init__(self, *, dry_run: bool = False) -> None:
|
|
40
|
+
self.dry_run = dry_run
|
|
41
|
+
|
|
42
|
+
def run(self, step: InstallStep, *, keep_going: bool = False) -> int:
|
|
43
|
+
print(quote_command(step.command))
|
|
44
|
+
if self.dry_run:
|
|
45
|
+
return 0
|
|
46
|
+
result = subprocess.run(step.command, check=False)
|
|
47
|
+
if result.returncode and not keep_going:
|
|
48
|
+
raise SystemExit(result.returncode)
|
|
49
|
+
return result.returncode
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GhSkillBackend:
|
|
53
|
+
name = "gh"
|
|
54
|
+
|
|
55
|
+
def install_steps(
|
|
56
|
+
self,
|
|
57
|
+
manifest: Manifest,
|
|
58
|
+
source: SourceSpec,
|
|
59
|
+
skill: SkillSpec,
|
|
60
|
+
) -> list[InstallStep]:
|
|
61
|
+
command = [
|
|
62
|
+
"gh",
|
|
63
|
+
"skill",
|
|
64
|
+
"install",
|
|
65
|
+
source.source,
|
|
66
|
+
skill.spec,
|
|
67
|
+
"--dir",
|
|
68
|
+
str(manifest.shared_dir),
|
|
69
|
+
"--force",
|
|
70
|
+
]
|
|
71
|
+
if source.allow_hidden_dirs:
|
|
72
|
+
command.append("--allow-hidden-dirs")
|
|
73
|
+
if skill.pin:
|
|
74
|
+
command.extend(["--pin", skill.pin])
|
|
75
|
+
return [InstallStep(label=f"{source.source}/{skill.spec}", command=command)]
|
|
76
|
+
|
|
77
|
+
def update_steps(self, manifest: Manifest) -> list[InstallStep]:
|
|
78
|
+
return [
|
|
79
|
+
InstallStep(
|
|
80
|
+
label="gh skill update",
|
|
81
|
+
command=["gh", "skill", "update", "--all", "--dir", str(manifest.shared_dir)],
|
|
82
|
+
)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_BACKENDS: dict[str, Backend] = {
|
|
87
|
+
GhSkillBackend.name: GhSkillBackend(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_backend(name: str) -> Backend:
|
|
92
|
+
try:
|
|
93
|
+
return _BACKENDS[name]
|
|
94
|
+
except KeyError as error:
|
|
95
|
+
supported = ", ".join(sorted(_BACKENDS))
|
|
96
|
+
message = f"unsupported backend {name!r}; supported backends: {supported}"
|
|
97
|
+
raise ValueError(message) from error
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def symlink_step(manifest: Manifest, skill: SkillSpec) -> InstallStep | None:
|
|
101
|
+
if "claude-code" not in manifest.agents:
|
|
102
|
+
return None
|
|
103
|
+
source = manifest.shared_dir / skill.name
|
|
104
|
+
target = manifest.claude_dir / skill.name
|
|
105
|
+
rel = os.path.relpath(source, start=target.parent)
|
|
106
|
+
return InstallStep(
|
|
107
|
+
label=f"link claude-code/{skill.name}",
|
|
108
|
+
command=["ln", "-sfn", rel, str(target)],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ensure_claude_symlink(manifest: Manifest, skill: SkillSpec) -> None:
|
|
113
|
+
if "claude-code" not in manifest.agents:
|
|
114
|
+
return
|
|
115
|
+
source = manifest.shared_dir / skill.name
|
|
116
|
+
target = manifest.claude_dir / skill.name
|
|
117
|
+
if not source.exists():
|
|
118
|
+
raise SystemExit(f"cannot link missing skill: {source}")
|
|
119
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
rel = os.path.relpath(source, start=target.parent)
|
|
121
|
+
if target.is_symlink() or target.exists():
|
|
122
|
+
if target.is_dir() and not target.is_symlink():
|
|
123
|
+
subprocess.run(["rm", "-rf", str(target)], check=True)
|
|
124
|
+
else:
|
|
125
|
+
target.unlink()
|
|
126
|
+
target.symlink_to(rel, target_is_directory=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def installed_skill_names(path: Path) -> set[str]:
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return set()
|
|
132
|
+
return {item.name for item in path.iterdir() if item.is_dir() and (item / "SKILL.md").exists()}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .backends import (
|
|
10
|
+
InstallStep,
|
|
11
|
+
Runner,
|
|
12
|
+
ensure_claude_symlink,
|
|
13
|
+
get_backend,
|
|
14
|
+
installed_skill_names,
|
|
15
|
+
quote_command,
|
|
16
|
+
symlink_step,
|
|
17
|
+
)
|
|
18
|
+
from .manifest import Manifest, load_manifest, manifest_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def use_color() -> bool:
|
|
22
|
+
return os.environ.get("NO_COLOR") is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def red(text: str) -> str:
|
|
26
|
+
return f"\033[31m{text}\033[0m" if use_color() else text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def green(text: str) -> str:
|
|
30
|
+
return f"\033[32m{text}\033[0m" if use_color() else text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def diff_sets(manifest: Manifest) -> tuple[list[str], list[str]]:
|
|
34
|
+
desired = manifest.desired_skill_names
|
|
35
|
+
installed = installed_skill_names(manifest.shared_dir)
|
|
36
|
+
return sorted(desired - installed), sorted(installed - desired)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_diff(manifest: Manifest, *, warning: bool = False) -> bool:
|
|
40
|
+
missing, extra = diff_sets(manifest)
|
|
41
|
+
if not missing and not extra:
|
|
42
|
+
return False
|
|
43
|
+
if warning:
|
|
44
|
+
print(f"⚠️ Installed skills differ from {manifest.path}:", file=sys.stderr)
|
|
45
|
+
for name in missing:
|
|
46
|
+
print(red(f"- {name}"), file=sys.stderr)
|
|
47
|
+
for name in extra:
|
|
48
|
+
print(green(f"+ {name}"), file=sys.stderr)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def iter_install_plan(manifest: Manifest) -> Iterator[InstallStep]:
|
|
53
|
+
for source in manifest.sources:
|
|
54
|
+
backend = get_backend(source.backend)
|
|
55
|
+
for skill in source.skills:
|
|
56
|
+
yield from backend.install_steps(manifest, source, skill)
|
|
57
|
+
if step := symlink_step(manifest, skill):
|
|
58
|
+
yield step
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def command_plan(manifest: Manifest) -> int:
|
|
62
|
+
for step in iter_install_plan(manifest):
|
|
63
|
+
print(quote_command(step.command))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def command_diff(manifest: Manifest) -> int:
|
|
68
|
+
return 1 if print_diff(manifest) else 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def command_apply(manifest: Manifest, *, dry_run: bool = False) -> int:
|
|
72
|
+
print_diff(manifest, warning=True)
|
|
73
|
+
runner = Runner(dry_run=dry_run)
|
|
74
|
+
for source in manifest.sources:
|
|
75
|
+
backend = get_backend(source.backend)
|
|
76
|
+
for skill in source.skills:
|
|
77
|
+
for step in backend.install_steps(manifest, source, skill):
|
|
78
|
+
print(f"📦 Installing {step.label} → {manifest.shared_dir}…")
|
|
79
|
+
runner.run(step)
|
|
80
|
+
if not dry_run:
|
|
81
|
+
ensure_claude_symlink(manifest, skill)
|
|
82
|
+
else:
|
|
83
|
+
link_step = symlink_step(manifest, skill)
|
|
84
|
+
if link_step:
|
|
85
|
+
print(quote_command(link_step.command))
|
|
86
|
+
print("✅ Skills applied.")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def command_update(manifest: Manifest, *, dry_run: bool = False) -> int:
|
|
91
|
+
print_diff(manifest, warning=True)
|
|
92
|
+
runner = Runner(dry_run=dry_run)
|
|
93
|
+
backends = {source.backend for source in manifest.sources}
|
|
94
|
+
print("🧠 Updating skills…")
|
|
95
|
+
for backend_name in sorted(backends):
|
|
96
|
+
backend = get_backend(backend_name)
|
|
97
|
+
for step in backend.update_steps(manifest):
|
|
98
|
+
runner.run(step, keep_going=True)
|
|
99
|
+
print("✅ Skills updated.")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
104
|
+
parser = argparse.ArgumentParser(description="Declarative agent skill manager")
|
|
105
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
106
|
+
parser.add_argument("--config", "-c", help="Manifest path (default: ~/.agents/.skill.yaml)")
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--dry-run",
|
|
109
|
+
action="store_true",
|
|
110
|
+
help="Print commands without applying changes",
|
|
111
|
+
)
|
|
112
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
113
|
+
for name in ["path", "plan", "diff", "apply", "sync", "update"]:
|
|
114
|
+
subparsers.add_parser(name)
|
|
115
|
+
return parser
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv: list[str] | None = None) -> int:
|
|
119
|
+
parser = build_parser()
|
|
120
|
+
args = parser.parse_args(argv)
|
|
121
|
+
command = args.command or "plan"
|
|
122
|
+
path = manifest_path(args.config)
|
|
123
|
+
|
|
124
|
+
if command == "path":
|
|
125
|
+
print(path)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
manifest = load_manifest(path)
|
|
130
|
+
except Exception as error:
|
|
131
|
+
print(f"skeel: {error}", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
if command == "plan":
|
|
135
|
+
return command_plan(manifest)
|
|
136
|
+
if command == "diff":
|
|
137
|
+
return command_diff(manifest)
|
|
138
|
+
if command in {"apply", "sync"}:
|
|
139
|
+
return command_apply(manifest, dry_run=args.dry_run)
|
|
140
|
+
if command == "update":
|
|
141
|
+
return command_update(manifest, dry_run=args.dry_run)
|
|
142
|
+
|
|
143
|
+
parser.error(f"unknown command: {command}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
DEFAULT_MANIFEST = "~/.agents/.skill.yaml"
|
|
11
|
+
DEFAULT_SHARED_DIR = "~/.agents/skills"
|
|
12
|
+
DEFAULT_CLAUDE_DIR = "~/.claude/skills"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SkillSpec:
|
|
17
|
+
spec: str
|
|
18
|
+
name: str
|
|
19
|
+
pin: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SourceSpec:
|
|
24
|
+
source: str
|
|
25
|
+
skills: tuple[SkillSpec, ...]
|
|
26
|
+
backend: str = "gh"
|
|
27
|
+
allow_hidden_dirs: bool = False
|
|
28
|
+
private: bool = False
|
|
29
|
+
pin: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Manifest:
|
|
34
|
+
path: Path
|
|
35
|
+
agents: tuple[str, ...]
|
|
36
|
+
sources: tuple[SourceSpec, ...]
|
|
37
|
+
shared_dir: Path = field(default_factory=lambda: Path(DEFAULT_SHARED_DIR).expanduser())
|
|
38
|
+
claude_dir: Path = field(default_factory=lambda: Path(DEFAULT_CLAUDE_DIR).expanduser())
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def desired_skill_names(self) -> set[str]:
|
|
42
|
+
return {skill.name for source in self.sources for skill in source.skills}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def manifest_path(value: str | None = None) -> Path:
|
|
46
|
+
if value:
|
|
47
|
+
return Path(value).expanduser()
|
|
48
|
+
return Path(os.environ.get("SKEEL_MANIFEST", DEFAULT_MANIFEST)).expanduser()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def infer_skill_name(spec: str) -> str:
|
|
52
|
+
spec = spec.split("@", 1)[0].rstrip("/")
|
|
53
|
+
if spec == "*":
|
|
54
|
+
raise ValueError("wildcard skills are not supported; list desired skills explicitly")
|
|
55
|
+
if spec.endswith("/SKILL.md"):
|
|
56
|
+
return Path(spec).parent.name
|
|
57
|
+
if spec == "SKILL.md":
|
|
58
|
+
raise ValueError("root SKILL.md entries require an explicit name")
|
|
59
|
+
return Path(spec).name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_skill(value: Any, *, source_pin: str | None = None) -> SkillSpec:
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return SkillSpec(spec=value, name=infer_skill_name(value), pin=source_pin)
|
|
65
|
+
if not isinstance(value, dict):
|
|
66
|
+
raise ValueError(f"invalid skill entry: {value!r}")
|
|
67
|
+
|
|
68
|
+
spec = str(value.get("spec") or value.get("path") or value.get("name") or "")
|
|
69
|
+
if not spec:
|
|
70
|
+
raise ValueError(f"skill entry missing name/spec/path: {value!r}")
|
|
71
|
+
name = str(value.get("name") or infer_skill_name(spec))
|
|
72
|
+
pin = value.get("pin", source_pin)
|
|
73
|
+
return SkillSpec(spec=spec, name=name, pin=str(pin) if pin else None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_source(value: Any) -> SourceSpec:
|
|
77
|
+
if not isinstance(value, dict):
|
|
78
|
+
raise ValueError(f"invalid source entry: {value!r}")
|
|
79
|
+
source = str(value.get("source") or "")
|
|
80
|
+
if not source:
|
|
81
|
+
raise ValueError(f"source entry missing source: {value!r}")
|
|
82
|
+
backend = str(value.get("backend") or "gh")
|
|
83
|
+
source_pin = value.get("pin")
|
|
84
|
+
pin = str(source_pin) if source_pin else None
|
|
85
|
+
skills = tuple(parse_skill(skill, source_pin=pin) for skill in value.get("skills") or [])
|
|
86
|
+
if not skills:
|
|
87
|
+
raise ValueError(f"source {source} has no skills")
|
|
88
|
+
return SourceSpec(
|
|
89
|
+
source=source,
|
|
90
|
+
skills=skills,
|
|
91
|
+
backend=backend,
|
|
92
|
+
allow_hidden_dirs=bool(value.get("allow_hidden_dirs") or value.get("allow-hidden-dirs")),
|
|
93
|
+
private=bool(value.get("private")),
|
|
94
|
+
pin=str(source_pin) if source_pin else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_manifest(path: Path) -> Manifest:
|
|
99
|
+
data = yaml.safe_load(path.read_text())
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise ValueError(f"manifest must be a YAML mapping: {path}")
|
|
102
|
+
|
|
103
|
+
agents = tuple(str(agent) for agent in data.get("agents") or [])
|
|
104
|
+
if not agents:
|
|
105
|
+
raise ValueError("manifest must define at least one agent")
|
|
106
|
+
|
|
107
|
+
sources = tuple(parse_source(source) for source in data.get("sources") or [])
|
|
108
|
+
if not sources:
|
|
109
|
+
raise ValueError("manifest must define at least one source")
|
|
110
|
+
|
|
111
|
+
return Manifest(
|
|
112
|
+
path=path,
|
|
113
|
+
agents=agents,
|
|
114
|
+
sources=sources,
|
|
115
|
+
shared_dir=Path(str(data.get("shared_dir") or DEFAULT_SHARED_DIR)).expanduser(),
|
|
116
|
+
claude_dir=Path(str(data.get("claude_dir") or DEFAULT_CLAUDE_DIR)).expanduser(),
|
|
117
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from skeel.backends import GhSkillBackend, quote_command, symlink_step
|
|
4
|
+
from skeel.manifest import Manifest, SkillSpec, SourceSpec
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_gh_install_step() -> None:
|
|
8
|
+
manifest = Manifest(
|
|
9
|
+
path=Path("manifest.yaml"),
|
|
10
|
+
agents=("universal", "claude-code"),
|
|
11
|
+
sources=(),
|
|
12
|
+
shared_dir=Path("/tmp/agents/skills"),
|
|
13
|
+
claude_dir=Path("/tmp/claude/skills"),
|
|
14
|
+
)
|
|
15
|
+
source = SourceSpec(
|
|
16
|
+
source="openclaw/gogcli",
|
|
17
|
+
skills=(SkillSpec(spec="gog", name="gog"),),
|
|
18
|
+
allow_hidden_dirs=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
step = GhSkillBackend().install_steps(manifest, source, source.skills[0])[0]
|
|
22
|
+
|
|
23
|
+
assert step.command == [
|
|
24
|
+
"gh",
|
|
25
|
+
"skill",
|
|
26
|
+
"install",
|
|
27
|
+
"openclaw/gogcli",
|
|
28
|
+
"gog",
|
|
29
|
+
"--dir",
|
|
30
|
+
"/tmp/agents/skills",
|
|
31
|
+
"--force",
|
|
32
|
+
"--allow-hidden-dirs",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_symlink_step_for_claude_code() -> None:
|
|
37
|
+
manifest = Manifest(
|
|
38
|
+
path=Path("manifest.yaml"),
|
|
39
|
+
agents=("universal", "claude-code"),
|
|
40
|
+
sources=(),
|
|
41
|
+
shared_dir=Path("/Users/me/.agents/skills"),
|
|
42
|
+
claude_dir=Path("/Users/me/.claude/skills"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
step = symlink_step(manifest, SkillSpec(spec="mavam", name="mavam"))
|
|
46
|
+
|
|
47
|
+
assert step is not None
|
|
48
|
+
assert step.command == [
|
|
49
|
+
"ln",
|
|
50
|
+
"-sfn",
|
|
51
|
+
"../../.agents/skills/mavam",
|
|
52
|
+
"/Users/me/.claude/skills/mavam",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_quote_command() -> None:
|
|
57
|
+
assert quote_command(["gh", "skill", "install", "owner/repo", "skills/foo/SKILL.md"]) == (
|
|
58
|
+
"gh skill install owner/repo skills/foo/SKILL.md"
|
|
59
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from skeel.manifest import infer_skill_name, load_manifest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_infer_skill_name_from_plain_name() -> None:
|
|
9
|
+
assert infer_skill_name("mavam") == "mavam"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_infer_skill_name_from_skill_path() -> None:
|
|
13
|
+
assert infer_skill_name("skills/foo/SKILL.md") == "foo"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_wildcard_is_rejected() -> None:
|
|
17
|
+
with pytest.raises(ValueError, match="wildcard"):
|
|
18
|
+
infer_skill_name("*")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_load_manifest(tmp_path: Path) -> None:
|
|
22
|
+
manifest_path = tmp_path / "skills.yaml"
|
|
23
|
+
manifest_path.write_text(
|
|
24
|
+
"""
|
|
25
|
+
version: 1
|
|
26
|
+
shared_dir: ~/.agents/skills
|
|
27
|
+
agents:
|
|
28
|
+
- universal
|
|
29
|
+
- claude-code
|
|
30
|
+
sources:
|
|
31
|
+
- source: mavam/skills
|
|
32
|
+
private: true
|
|
33
|
+
skills:
|
|
34
|
+
- mavam
|
|
35
|
+
- source: openclaw/gogcli
|
|
36
|
+
allow-hidden-dirs: true
|
|
37
|
+
skills:
|
|
38
|
+
- gog
|
|
39
|
+
""".strip()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
manifest = load_manifest(manifest_path)
|
|
43
|
+
|
|
44
|
+
assert manifest.path == manifest_path
|
|
45
|
+
assert manifest.agents == ("universal", "claude-code")
|
|
46
|
+
assert manifest.desired_skill_names == {"mavam", "gog"}
|
|
47
|
+
assert manifest.sources[1].allow_hidden_dirs is True
|