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 ADDED
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ *.egg-info/
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,3 @@
1
+ """Declarative agent skill management."""
2
+
3
+ __version__ = "0.1.0"
@@ -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