docketeer-agentskills 0.0.5__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.
- docketeer_agentskills-0.0.5/.gitignore +11 -0
- docketeer_agentskills-0.0.5/PKG-INFO +49 -0
- docketeer_agentskills-0.0.5/README.md +31 -0
- docketeer_agentskills-0.0.5/pyproject.toml +62 -0
- docketeer_agentskills-0.0.5/src/docketeer_agentskills/__init__.py +3 -0
- docketeer_agentskills-0.0.5/src/docketeer_agentskills/discovery.py +95 -0
- docketeer_agentskills-0.0.5/src/docketeer_agentskills/prompt.py +25 -0
- docketeer_agentskills-0.0.5/src/docketeer_agentskills/tools.py +129 -0
- docketeer_agentskills-0.0.5/tests/conftest.py +42 -0
- docketeer_agentskills-0.0.5/tests/test_discovery.py +138 -0
- docketeer_agentskills-0.0.5/tests/test_prompt.py +29 -0
- docketeer_agentskills-0.0.5/tests/test_tools.py +380 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docketeer-agentskills
|
|
3
|
+
Version: 0.0.5
|
|
4
|
+
Summary: Agent Skills plugin for Docketeer
|
|
5
|
+
Project-URL: Homepage, https://github.com/chrisguidry/docketeer
|
|
6
|
+
Project-URL: Repository, https://github.com/chrisguidry/docketeer
|
|
7
|
+
Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
|
|
8
|
+
Author-email: Chris Guidry <guid@omg.lol>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: docketeer
|
|
16
|
+
Requires-Dist: pyyaml
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# docketeer-agentskills
|
|
20
|
+
|
|
21
|
+
[Agent Skills](https://agentskills.io/specification) plugin for
|
|
22
|
+
[Docketeer](https://github.com/chrisguidry/docketeer).
|
|
23
|
+
|
|
24
|
+
Adds support for installing, managing, and using skills packaged as directories
|
|
25
|
+
with a `SKILL.md` file (YAML frontmatter + markdown instructions).
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | Description |
|
|
30
|
+
|------|-------------|
|
|
31
|
+
| `list_skills` | List installed skills with descriptions |
|
|
32
|
+
| `activate_skill` | Load a skill's full instructions |
|
|
33
|
+
| `read_skill_file` | Read any file from a skill directory |
|
|
34
|
+
| `install_skill` | Install a skill from a git repository |
|
|
35
|
+
| `uninstall_skill` | Remove an installed skill |
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
Skills live in `{workspace}/skills/`. The plugin provides three levels of
|
|
40
|
+
progressive disclosure:
|
|
41
|
+
|
|
42
|
+
1. **System prompt** — skill names and descriptions are always available
|
|
43
|
+
2. **activate_skill** — loads the full SKILL.md body on demand
|
|
44
|
+
3. **read_skill_file** — reads any file from the skill directory on demand
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
No configuration required. Skills are discovered automatically from the
|
|
49
|
+
`skills/` directory in the agent's workspace.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# docketeer-agentskills
|
|
2
|
+
|
|
3
|
+
[Agent Skills](https://agentskills.io/specification) plugin for
|
|
4
|
+
[Docketeer](https://github.com/chrisguidry/docketeer).
|
|
5
|
+
|
|
6
|
+
Adds support for installing, managing, and using skills packaged as directories
|
|
7
|
+
with a `SKILL.md` file (YAML frontmatter + markdown instructions).
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | Description |
|
|
12
|
+
|------|-------------|
|
|
13
|
+
| `list_skills` | List installed skills with descriptions |
|
|
14
|
+
| `activate_skill` | Load a skill's full instructions |
|
|
15
|
+
| `read_skill_file` | Read any file from a skill directory |
|
|
16
|
+
| `install_skill` | Install a skill from a git repository |
|
|
17
|
+
| `uninstall_skill` | Remove an installed skill |
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Skills live in `{workspace}/skills/`. The plugin provides three levels of
|
|
22
|
+
progressive disclosure:
|
|
23
|
+
|
|
24
|
+
1. **System prompt** — skill names and descriptions are always available
|
|
25
|
+
2. **activate_skill** — loads the full SKILL.md body on demand
|
|
26
|
+
3. **read_skill_file** — reads any file from the skill directory on demand
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
No configuration required. Skills are discovered automatically from the
|
|
31
|
+
`skills/` directory in the agent's workspace.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "docketeer-agentskills"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Agent Skills plugin for Docketeer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Chris Guidry", email = "guid@omg.lol" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"docketeer",
|
|
19
|
+
"pyyaml",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/chrisguidry/docketeer"
|
|
24
|
+
Repository = "https://github.com/chrisguidry/docketeer"
|
|
25
|
+
Issues = "https://github.com/chrisguidry/docketeer/issues"
|
|
26
|
+
|
|
27
|
+
[project.entry-points."docketeer.tools"]
|
|
28
|
+
agentskills = "docketeer_agentskills"
|
|
29
|
+
|
|
30
|
+
[project.entry-points."docketeer.prompt"]
|
|
31
|
+
agentskills = "docketeer_agentskills.prompt:provide_skill_catalog"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
raw-options.root = ".."
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/docketeer_agentskills"]
|
|
43
|
+
|
|
44
|
+
[tool.uv.sources]
|
|
45
|
+
docketeer = { workspace = true }
|
|
46
|
+
|
|
47
|
+
[tool.pytest_env]
|
|
48
|
+
DOCKETEER_ANTHROPIC_API_KEY = "test-key"
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
minversion = "9.0"
|
|
52
|
+
addopts = [
|
|
53
|
+
"--import-mode=importlib",
|
|
54
|
+
"--cov=docketeer_agentskills",
|
|
55
|
+
"--cov-branch",
|
|
56
|
+
"--cov-report=term-missing",
|
|
57
|
+
"--cov-fail-under=100",
|
|
58
|
+
]
|
|
59
|
+
asyncio_mode = "auto"
|
|
60
|
+
filterwarnings = [
|
|
61
|
+
"error",
|
|
62
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Skill discovery: parse SKILL.md files and scan the skills directory."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Skill:
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
path: Path
|
|
20
|
+
body: str
|
|
21
|
+
license: str = ""
|
|
22
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_skill(skill_dir: Path) -> Skill:
|
|
26
|
+
"""Parse a SKILL.md file from a skill directory."""
|
|
27
|
+
skill_md = skill_dir / "SKILL.md"
|
|
28
|
+
if not skill_md.exists():
|
|
29
|
+
raise FileNotFoundError(f"No SKILL.md in {skill_dir}")
|
|
30
|
+
|
|
31
|
+
content = skill_md.read_text()
|
|
32
|
+
frontmatter, body = _split_frontmatter(content)
|
|
33
|
+
|
|
34
|
+
name = frontmatter.get("name", "")
|
|
35
|
+
if not name or not NAME_PATTERN.match(name):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Invalid skill name {name!r}: must be 1-64 lowercase"
|
|
38
|
+
" alphanumeric characters or hyphens"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if name != skill_dir.name:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Skill name {name!r} does not match directory name {skill_dir.name!r}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
description = frontmatter.get("description", "")
|
|
47
|
+
if not description:
|
|
48
|
+
raise ValueError(f"Skill {name!r} is missing a description")
|
|
49
|
+
|
|
50
|
+
return Skill(
|
|
51
|
+
name=name,
|
|
52
|
+
description=description,
|
|
53
|
+
path=skill_dir,
|
|
54
|
+
body=body.strip(),
|
|
55
|
+
license=frontmatter.get("license", ""),
|
|
56
|
+
metadata={
|
|
57
|
+
k: str(v)
|
|
58
|
+
for k, v in frontmatter.items()
|
|
59
|
+
if k not in {"name", "description", "license"}
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def discover_skills(skills_dir: Path) -> dict[str, Skill]:
|
|
65
|
+
"""Scan a directory for skill subdirectories and return valid skills."""
|
|
66
|
+
if not skills_dir.is_dir():
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
skills: dict[str, Skill] = {}
|
|
70
|
+
for child in sorted(skills_dir.iterdir()):
|
|
71
|
+
if not child.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
if not (child / "SKILL.md").exists():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
skill = parse_skill(child)
|
|
77
|
+
skills[skill.name] = skill
|
|
78
|
+
except (ValueError, FileNotFoundError):
|
|
79
|
+
log.warning("Skipping invalid skill in %s", child.name, exc_info=True)
|
|
80
|
+
|
|
81
|
+
return skills
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _split_frontmatter(content: str) -> tuple[dict, str]:
|
|
85
|
+
"""Split YAML frontmatter from markdown body."""
|
|
86
|
+
if not content.startswith("---"):
|
|
87
|
+
return {}, content
|
|
88
|
+
|
|
89
|
+
parts = content.split("---", 2)
|
|
90
|
+
if len(parts) < 3:
|
|
91
|
+
return {}, content
|
|
92
|
+
|
|
93
|
+
raw = yaml.safe_load(parts[1])
|
|
94
|
+
frontmatter = raw if isinstance(raw, dict) else {}
|
|
95
|
+
return frontmatter, parts[2]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""System prompt provider for installed skills."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from docketeer.prompt import SystemBlock
|
|
6
|
+
|
|
7
|
+
from .discovery import discover_skills
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def provide_skill_catalog(workspace: Path) -> list[SystemBlock]:
|
|
11
|
+
"""Build a skill catalog block for the system prompt."""
|
|
12
|
+
skills = discover_skills(workspace / "skills")
|
|
13
|
+
if not skills:
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
lines = [
|
|
17
|
+
"## Installed skills",
|
|
18
|
+
"",
|
|
19
|
+
"Use `activate_skill` to load a skill's full instructions when relevant.",
|
|
20
|
+
"",
|
|
21
|
+
]
|
|
22
|
+
for skill in skills.values():
|
|
23
|
+
lines.append(f"- **{skill.name}**: {skill.description}")
|
|
24
|
+
|
|
25
|
+
return [SystemBlock(text="\n".join(lines))]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Skill management tools."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from docketeer.tools import ToolContext, _safe_path, registry
|
|
9
|
+
|
|
10
|
+
from .discovery import discover_skills, parse_skill
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@registry.tool
|
|
14
|
+
async def list_skills(ctx: ToolContext) -> str:
|
|
15
|
+
"""List all installed skills with their descriptions."""
|
|
16
|
+
skills = discover_skills(ctx.workspace / "skills")
|
|
17
|
+
if not skills:
|
|
18
|
+
return "No skills installed."
|
|
19
|
+
lines = []
|
|
20
|
+
for skill in skills.values():
|
|
21
|
+
lines.append(f"- {skill.name}: {skill.description}")
|
|
22
|
+
return "\n".join(lines)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@registry.tool
|
|
26
|
+
async def activate_skill(ctx: ToolContext, name: str) -> str:
|
|
27
|
+
"""Load the full instructions for an installed skill.
|
|
28
|
+
|
|
29
|
+
name: skill name to activate
|
|
30
|
+
"""
|
|
31
|
+
skill_dir = _safe_path(ctx.workspace, f"skills/{name}")
|
|
32
|
+
if not skill_dir.is_dir():
|
|
33
|
+
return f"Skill not found: {name}"
|
|
34
|
+
try:
|
|
35
|
+
skill = parse_skill(skill_dir)
|
|
36
|
+
except (ValueError, FileNotFoundError) as e:
|
|
37
|
+
return f"Error loading skill {name}: {e}"
|
|
38
|
+
return skill.body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@registry.tool
|
|
42
|
+
async def read_skill_file(ctx: ToolContext, name: str, path: str) -> str:
|
|
43
|
+
"""Read a file from an installed skill's directory.
|
|
44
|
+
|
|
45
|
+
name: skill name
|
|
46
|
+
path: relative path within the skill directory
|
|
47
|
+
"""
|
|
48
|
+
skill_dir = _safe_path(ctx.workspace, f"skills/{name}")
|
|
49
|
+
if not skill_dir.is_dir():
|
|
50
|
+
return f"Skill not found: {name}"
|
|
51
|
+
target = (skill_dir / path).resolve()
|
|
52
|
+
if not str(target).startswith(str(skill_dir.resolve())):
|
|
53
|
+
return f"Path '{path}' is outside the skill directory"
|
|
54
|
+
if not target.exists():
|
|
55
|
+
return f"File not found: {path}"
|
|
56
|
+
if target.is_dir():
|
|
57
|
+
return "\n".join(
|
|
58
|
+
f"{e.name}/" if e.is_dir() else e.name for e in sorted(target.iterdir())
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
return target.read_text()
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
return f"Cannot read binary file: {path}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@registry.tool
|
|
67
|
+
async def install_skill(
|
|
68
|
+
ctx: ToolContext, url: str, name: str = "", path: str = ""
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Install a skill from a git repository.
|
|
71
|
+
|
|
72
|
+
url: git repository URL to clone
|
|
73
|
+
name: skill name (derived from URL or path if empty)
|
|
74
|
+
path: subdirectory within the repo containing SKILL.md
|
|
75
|
+
"""
|
|
76
|
+
if not shutil.which("git"):
|
|
77
|
+
return "git is not installed — cannot clone skill repositories"
|
|
78
|
+
|
|
79
|
+
if not name:
|
|
80
|
+
source = path.rstrip("/") if path else url.rstrip("/")
|
|
81
|
+
name = source.rsplit("/", 1)[-1].removesuffix(".git")
|
|
82
|
+
|
|
83
|
+
skills_dir = ctx.workspace / "skills"
|
|
84
|
+
skills_dir.mkdir(exist_ok=True)
|
|
85
|
+
target = skills_dir / name
|
|
86
|
+
|
|
87
|
+
if target.exists():
|
|
88
|
+
return f"Skill {name!r} is already installed"
|
|
89
|
+
|
|
90
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
91
|
+
clone_dir = Path(tmp) / "repo"
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "clone", "--depth", "1", url, str(clone_dir)],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
return f"Failed to clone: {result.stderr.strip()}"
|
|
99
|
+
|
|
100
|
+
source = clone_dir / path if path else clone_dir
|
|
101
|
+
|
|
102
|
+
if not source.is_dir():
|
|
103
|
+
return f"Path {path!r} not found in repository"
|
|
104
|
+
|
|
105
|
+
if not (source / "SKILL.md").exists():
|
|
106
|
+
return "Repository does not contain a SKILL.md file"
|
|
107
|
+
|
|
108
|
+
shutil.copytree(source, target)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
skill = parse_skill(target)
|
|
112
|
+
except (ValueError, FileNotFoundError) as e:
|
|
113
|
+
shutil.rmtree(target)
|
|
114
|
+
return f"Invalid skill: {e}"
|
|
115
|
+
|
|
116
|
+
return f"Installed skill {skill.name!r}: {skill.description}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@registry.tool
|
|
120
|
+
async def uninstall_skill(ctx: ToolContext, name: str) -> str:
|
|
121
|
+
"""Remove an installed skill.
|
|
122
|
+
|
|
123
|
+
name: skill name to remove
|
|
124
|
+
"""
|
|
125
|
+
skill_dir = _safe_path(ctx.workspace, f"skills/{name}")
|
|
126
|
+
if not skill_dir.is_dir():
|
|
127
|
+
return f"Skill not found: {name}"
|
|
128
|
+
shutil.rmtree(skill_dir)
|
|
129
|
+
return f"Removed skill {name!r}"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared test fixtures for docketeer-agentskills."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from docketeer.tools import ToolContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def workspace(tmp_path: Path) -> Path:
|
|
12
|
+
ws = tmp_path / "memory"
|
|
13
|
+
ws.mkdir()
|
|
14
|
+
return ws
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture()
|
|
18
|
+
def skills_dir(workspace: Path) -> Path:
|
|
19
|
+
d = workspace / "skills"
|
|
20
|
+
d.mkdir()
|
|
21
|
+
return d
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def tool_context(workspace: Path) -> ToolContext:
|
|
26
|
+
return ToolContext(workspace=workspace, room_id="room1")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_skill(
|
|
30
|
+
skills_dir: Path,
|
|
31
|
+
name: str = "test-skill",
|
|
32
|
+
description: str = "A test skill",
|
|
33
|
+
body: str = "## Instructions\n\nDo the thing.",
|
|
34
|
+
extra_frontmatter: str = "",
|
|
35
|
+
) -> Path:
|
|
36
|
+
skill_dir = skills_dir / name
|
|
37
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
frontmatter = f"name: {name}\ndescription: {description}"
|
|
39
|
+
if extra_frontmatter:
|
|
40
|
+
frontmatter += f"\n{extra_frontmatter}"
|
|
41
|
+
(skill_dir / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n{body}")
|
|
42
|
+
return skill_dir
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for skill discovery."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from docketeer_agentskills.discovery import (
|
|
8
|
+
_split_frontmatter,
|
|
9
|
+
discover_skills,
|
|
10
|
+
parse_skill,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .conftest import make_skill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_skill(skills_dir: Path):
|
|
17
|
+
skill_dir = make_skill(skills_dir)
|
|
18
|
+
skill = parse_skill(skill_dir)
|
|
19
|
+
assert skill.name == "test-skill"
|
|
20
|
+
assert skill.description == "A test skill"
|
|
21
|
+
assert skill.path == skill_dir
|
|
22
|
+
assert "Instructions" in skill.body
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_parse_skill_with_license(skills_dir: Path):
|
|
26
|
+
skill_dir = make_skill(skills_dir, extra_frontmatter="license: MIT")
|
|
27
|
+
skill = parse_skill(skill_dir)
|
|
28
|
+
assert skill.license == "MIT"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_parse_skill_extra_metadata(skills_dir: Path):
|
|
32
|
+
skill_dir = make_skill(skills_dir, extra_frontmatter="version: '1.0'\nauthor: me")
|
|
33
|
+
skill = parse_skill(skill_dir)
|
|
34
|
+
assert skill.metadata == {"version": "1.0", "author": "me"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_parse_skill_no_skill_md(skills_dir: Path):
|
|
38
|
+
skill_dir = skills_dir / "empty-skill"
|
|
39
|
+
skill_dir.mkdir()
|
|
40
|
+
with pytest.raises(FileNotFoundError, match="No SKILL.md"):
|
|
41
|
+
parse_skill(skill_dir)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_parse_skill_invalid_name(skills_dir: Path):
|
|
45
|
+
skill_dir = skills_dir / "Bad_Name"
|
|
46
|
+
skill_dir.mkdir()
|
|
47
|
+
(skill_dir / "SKILL.md").write_text(
|
|
48
|
+
"---\nname: Bad_Name\ndescription: bad\n---\nbody"
|
|
49
|
+
)
|
|
50
|
+
with pytest.raises(ValueError, match="Invalid skill name"):
|
|
51
|
+
parse_skill(skill_dir)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_parse_skill_name_mismatch(skills_dir: Path):
|
|
55
|
+
skill_dir = skills_dir / "wrong-dir"
|
|
56
|
+
skill_dir.mkdir()
|
|
57
|
+
(skill_dir / "SKILL.md").write_text(
|
|
58
|
+
"---\nname: correct-name\ndescription: test\n---\nbody"
|
|
59
|
+
)
|
|
60
|
+
with pytest.raises(ValueError, match="does not match directory name"):
|
|
61
|
+
parse_skill(skill_dir)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_parse_skill_missing_description(skills_dir: Path):
|
|
65
|
+
skill_dir = skills_dir / "no-desc"
|
|
66
|
+
skill_dir.mkdir()
|
|
67
|
+
(skill_dir / "SKILL.md").write_text("---\nname: no-desc\n---\nbody")
|
|
68
|
+
with pytest.raises(ValueError, match="missing a description"):
|
|
69
|
+
parse_skill(skill_dir)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_parse_skill_missing_name(skills_dir: Path):
|
|
73
|
+
skill_dir = skills_dir / "no-name"
|
|
74
|
+
skill_dir.mkdir()
|
|
75
|
+
(skill_dir / "SKILL.md").write_text("---\ndescription: test\n---\nbody")
|
|
76
|
+
with pytest.raises(ValueError, match="Invalid skill name"):
|
|
77
|
+
parse_skill(skill_dir)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_parse_skill_single_char_name(skills_dir: Path):
|
|
81
|
+
skill_dir = skills_dir / "a"
|
|
82
|
+
skill_dir.mkdir()
|
|
83
|
+
(skill_dir / "SKILL.md").write_text("---\nname: a\ndescription: one\n---\nbody")
|
|
84
|
+
skill = parse_skill(skill_dir)
|
|
85
|
+
assert skill.name == "a"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_discover_skills(skills_dir: Path):
|
|
89
|
+
make_skill(skills_dir, name="skill-a", description="Alpha skill")
|
|
90
|
+
make_skill(skills_dir, name="skill-b", description="Beta skill")
|
|
91
|
+
skills = discover_skills(skills_dir)
|
|
92
|
+
assert set(skills.keys()) == {"skill-a", "skill-b"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_discover_skills_skips_invalid(skills_dir: Path):
|
|
96
|
+
make_skill(skills_dir, name="good-skill", description="Good")
|
|
97
|
+
bad_dir = skills_dir / "Bad_Skill"
|
|
98
|
+
bad_dir.mkdir()
|
|
99
|
+
(bad_dir / "SKILL.md").write_text("---\nname: Bad_Skill\ndescription: bad\n---\n")
|
|
100
|
+
skills = discover_skills(skills_dir)
|
|
101
|
+
assert list(skills.keys()) == ["good-skill"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_discover_skills_skips_dirs_without_skill_md(skills_dir: Path):
|
|
105
|
+
make_skill(skills_dir, name="real-skill", description="Real")
|
|
106
|
+
(skills_dir / "not-a-skill").mkdir()
|
|
107
|
+
skills = discover_skills(skills_dir)
|
|
108
|
+
assert list(skills.keys()) == ["real-skill"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_discover_skills_skips_files(skills_dir: Path):
|
|
112
|
+
make_skill(skills_dir, name="real-skill", description="Real")
|
|
113
|
+
(skills_dir / "random-file.txt").write_text("hi")
|
|
114
|
+
skills = discover_skills(skills_dir)
|
|
115
|
+
assert list(skills.keys()) == ["real-skill"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_discover_skills_nonexistent_dir(tmp_path: Path):
|
|
119
|
+
skills = discover_skills(tmp_path / "nope")
|
|
120
|
+
assert skills == {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_split_frontmatter_no_markers():
|
|
124
|
+
fm, body = _split_frontmatter("just markdown")
|
|
125
|
+
assert fm == {}
|
|
126
|
+
assert body == "just markdown"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_split_frontmatter_incomplete_markers():
|
|
130
|
+
fm, body = _split_frontmatter("---\nname: test")
|
|
131
|
+
assert fm == {}
|
|
132
|
+
assert body == "---\nname: test"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_split_frontmatter_non_dict_yaml():
|
|
136
|
+
fm, body = _split_frontmatter("---\n- list item\n---\nbody")
|
|
137
|
+
assert fm == {}
|
|
138
|
+
assert body == "\nbody"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Tests for the skill catalog prompt provider."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from docketeer_agentskills.prompt import provide_skill_catalog
|
|
6
|
+
|
|
7
|
+
from .conftest import make_skill
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_provide_skill_catalog_no_skills_dir(workspace: Path):
|
|
11
|
+
blocks = provide_skill_catalog(workspace)
|
|
12
|
+
assert blocks == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_provide_skill_catalog_empty_skills_dir(skills_dir: Path, workspace: Path):
|
|
16
|
+
blocks = provide_skill_catalog(workspace)
|
|
17
|
+
assert blocks == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_provide_skill_catalog_with_skills(skills_dir: Path, workspace: Path):
|
|
21
|
+
make_skill(skills_dir, name="alpha", description="Alpha skill")
|
|
22
|
+
make_skill(skills_dir, name="beta", description="Beta skill")
|
|
23
|
+
blocks = provide_skill_catalog(workspace)
|
|
24
|
+
assert len(blocks) == 1
|
|
25
|
+
text = blocks[0].text
|
|
26
|
+
assert "## Installed skills" in text
|
|
27
|
+
assert "**alpha**: Alpha skill" in text
|
|
28
|
+
assert "**beta**: Beta skill" in text
|
|
29
|
+
assert "activate_skill" in text
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Tests for skill management tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from docketeer.tools import ToolContext, registry
|
|
8
|
+
|
|
9
|
+
from .conftest import make_skill
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def test_list_skills_none(tool_context: ToolContext):
|
|
13
|
+
result = await registry.execute("list_skills", {}, tool_context)
|
|
14
|
+
assert "No skills installed" in result
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_list_skills(tool_context: ToolContext, skills_dir: Path):
|
|
18
|
+
make_skill(skills_dir, name="my-skill", description="Does things")
|
|
19
|
+
result = await registry.execute("list_skills", {}, tool_context)
|
|
20
|
+
assert "my-skill: Does things" in result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_activate_skill(tool_context: ToolContext, skills_dir: Path):
|
|
24
|
+
make_skill(skills_dir, name="my-skill", body="## Step 1\n\nDo the thing.")
|
|
25
|
+
result = await registry.execute(
|
|
26
|
+
"activate_skill", {"name": "my-skill"}, tool_context
|
|
27
|
+
)
|
|
28
|
+
assert "Step 1" in result
|
|
29
|
+
assert "Do the thing." in result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_activate_skill_not_found(tool_context: ToolContext, skills_dir: Path):
|
|
33
|
+
result = await registry.execute(
|
|
34
|
+
"activate_skill", {"name": "nonexistent"}, tool_context
|
|
35
|
+
)
|
|
36
|
+
assert "Skill not found" in result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_activate_skill_invalid(tool_context: ToolContext, skills_dir: Path):
|
|
40
|
+
bad_dir = skills_dir / "bad-skill"
|
|
41
|
+
bad_dir.mkdir()
|
|
42
|
+
(bad_dir / "SKILL.md").write_text("---\nname: bad-skill\n---\nbody")
|
|
43
|
+
result = await registry.execute(
|
|
44
|
+
"activate_skill", {"name": "bad-skill"}, tool_context
|
|
45
|
+
)
|
|
46
|
+
assert "Error loading skill" in result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def test_read_skill_file(tool_context: ToolContext, skills_dir: Path):
|
|
50
|
+
skill_dir = make_skill(skills_dir)
|
|
51
|
+
scripts = skill_dir / "scripts"
|
|
52
|
+
scripts.mkdir()
|
|
53
|
+
(scripts / "run.py").write_text("print('hello')")
|
|
54
|
+
result = await registry.execute(
|
|
55
|
+
"read_skill_file",
|
|
56
|
+
{"name": "test-skill", "path": "scripts/run.py"},
|
|
57
|
+
tool_context,
|
|
58
|
+
)
|
|
59
|
+
assert "print('hello')" in result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_read_skill_file_directory(tool_context: ToolContext, skills_dir: Path):
|
|
63
|
+
skill_dir = make_skill(skills_dir)
|
|
64
|
+
scripts = skill_dir / "scripts"
|
|
65
|
+
scripts.mkdir()
|
|
66
|
+
(scripts / "a.py").write_text("")
|
|
67
|
+
(scripts / "b.py").write_text("")
|
|
68
|
+
result = await registry.execute(
|
|
69
|
+
"read_skill_file",
|
|
70
|
+
{"name": "test-skill", "path": "scripts"},
|
|
71
|
+
tool_context,
|
|
72
|
+
)
|
|
73
|
+
assert "a.py" in result
|
|
74
|
+
assert "b.py" in result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_read_skill_file_not_found(tool_context: ToolContext, skills_dir: Path):
|
|
78
|
+
make_skill(skills_dir)
|
|
79
|
+
result = await registry.execute(
|
|
80
|
+
"read_skill_file",
|
|
81
|
+
{"name": "test-skill", "path": "nonexistent.txt"},
|
|
82
|
+
tool_context,
|
|
83
|
+
)
|
|
84
|
+
assert "File not found" in result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_read_skill_file_skill_not_found(
|
|
88
|
+
tool_context: ToolContext, skills_dir: Path
|
|
89
|
+
):
|
|
90
|
+
result = await registry.execute(
|
|
91
|
+
"read_skill_file", {"name": "nope", "path": "file.txt"}, tool_context
|
|
92
|
+
)
|
|
93
|
+
assert "Skill not found" in result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_read_skill_file_path_traversal(
|
|
97
|
+
tool_context: ToolContext, skills_dir: Path
|
|
98
|
+
):
|
|
99
|
+
make_skill(skills_dir)
|
|
100
|
+
result = await registry.execute(
|
|
101
|
+
"read_skill_file",
|
|
102
|
+
{"name": "test-skill", "path": "../../etc/passwd"},
|
|
103
|
+
tool_context,
|
|
104
|
+
)
|
|
105
|
+
assert "outside the skill directory" in result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_read_skill_file_binary(tool_context: ToolContext, skills_dir: Path):
|
|
109
|
+
skill_dir = make_skill(skills_dir)
|
|
110
|
+
(skill_dir / "data.bin").write_bytes(b"\x00\x01\x02\xff\xfe")
|
|
111
|
+
result = await registry.execute(
|
|
112
|
+
"read_skill_file",
|
|
113
|
+
{"name": "test-skill", "path": "data.bin"},
|
|
114
|
+
tool_context,
|
|
115
|
+
)
|
|
116
|
+
assert "Cannot read binary file" in result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_install_skill_no_git(tool_context: ToolContext):
|
|
120
|
+
with patch("docketeer_agentskills.tools.shutil.which", return_value=None):
|
|
121
|
+
result = await registry.execute(
|
|
122
|
+
"install_skill", {"url": "https://example.com/repo.git"}, tool_context
|
|
123
|
+
)
|
|
124
|
+
assert "git is not installed" in result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_install_skill_already_exists(
|
|
128
|
+
tool_context: ToolContext, skills_dir: Path
|
|
129
|
+
):
|
|
130
|
+
make_skill(skills_dir, name="existing")
|
|
131
|
+
result = await registry.execute(
|
|
132
|
+
"install_skill",
|
|
133
|
+
{"url": "https://example.com/existing.git", "name": "existing"},
|
|
134
|
+
tool_context,
|
|
135
|
+
)
|
|
136
|
+
assert "already installed" in result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def test_install_skill_clone_failure(tool_context: ToolContext, skills_dir: Path):
|
|
140
|
+
with patch("docketeer_agentskills.tools.subprocess.run") as mock_run:
|
|
141
|
+
mock_run.return_value.returncode = 128
|
|
142
|
+
mock_run.return_value.stderr = "fatal: repo not found"
|
|
143
|
+
result = await registry.execute(
|
|
144
|
+
"install_skill",
|
|
145
|
+
{"url": "https://example.com/bad.git", "name": "bad"},
|
|
146
|
+
tool_context,
|
|
147
|
+
)
|
|
148
|
+
assert "Failed to clone" in result
|
|
149
|
+
assert not (skills_dir / "bad").exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def test_install_skill_no_skill_md(tool_context: ToolContext, skills_dir: Path):
|
|
153
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
154
|
+
clone_dir = Path(args[0][5])
|
|
155
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
(clone_dir / "README.md").write_text("hi")
|
|
157
|
+
|
|
158
|
+
class FakeResult:
|
|
159
|
+
returncode = 0
|
|
160
|
+
stderr = ""
|
|
161
|
+
|
|
162
|
+
return FakeResult()
|
|
163
|
+
|
|
164
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
165
|
+
result = await registry.execute(
|
|
166
|
+
"install_skill",
|
|
167
|
+
{"url": "https://example.com/no-skill-md.git", "name": "no-skill-md"},
|
|
168
|
+
tool_context,
|
|
169
|
+
)
|
|
170
|
+
assert "does not contain a SKILL.md" in result
|
|
171
|
+
assert not (skills_dir / "no-skill-md").exists()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def test_install_skill_invalid_skill_md(
|
|
175
|
+
tool_context: ToolContext, skills_dir: Path
|
|
176
|
+
):
|
|
177
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
178
|
+
clone_dir = Path(args[0][5])
|
|
179
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
(clone_dir / "SKILL.md").write_text("---\nname: bad-meta\n---\nbody")
|
|
181
|
+
|
|
182
|
+
class FakeResult:
|
|
183
|
+
returncode = 0
|
|
184
|
+
stderr = ""
|
|
185
|
+
|
|
186
|
+
return FakeResult()
|
|
187
|
+
|
|
188
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
189
|
+
result = await registry.execute(
|
|
190
|
+
"install_skill",
|
|
191
|
+
{"url": "https://example.com/bad-meta.git", "name": "bad-meta"},
|
|
192
|
+
tool_context,
|
|
193
|
+
)
|
|
194
|
+
assert "Invalid skill" in result
|
|
195
|
+
assert not (skills_dir / "bad-meta").exists()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def test_install_skill_success(tool_context: ToolContext, skills_dir: Path):
|
|
199
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
200
|
+
clone_dir = Path(args[0][5])
|
|
201
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
(clone_dir / "SKILL.md").write_text(
|
|
203
|
+
"---\nname: cool-skill\ndescription: Cool stuff\n---\nDo things"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
class FakeResult:
|
|
207
|
+
returncode = 0
|
|
208
|
+
stderr = ""
|
|
209
|
+
|
|
210
|
+
return FakeResult()
|
|
211
|
+
|
|
212
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
213
|
+
result = await registry.execute(
|
|
214
|
+
"install_skill",
|
|
215
|
+
{"url": "https://example.com/cool-skill.git"},
|
|
216
|
+
tool_context,
|
|
217
|
+
)
|
|
218
|
+
assert "Installed skill 'cool-skill'" in result
|
|
219
|
+
assert "Cool stuff" in result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def test_install_skill_derives_name_from_url(
|
|
223
|
+
tool_context: ToolContext, skills_dir: Path
|
|
224
|
+
):
|
|
225
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
226
|
+
clone_dir = Path(args[0][5])
|
|
227
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
(clone_dir / "SKILL.md").write_text(
|
|
229
|
+
"---\nname: my-repo\ndescription: From URL\n---\nbody"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
class FakeResult:
|
|
233
|
+
returncode = 0
|
|
234
|
+
stderr = ""
|
|
235
|
+
|
|
236
|
+
return FakeResult()
|
|
237
|
+
|
|
238
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
239
|
+
result = await registry.execute(
|
|
240
|
+
"install_skill",
|
|
241
|
+
{"url": "https://github.com/user/my-repo.git"},
|
|
242
|
+
tool_context,
|
|
243
|
+
)
|
|
244
|
+
assert "Installed skill 'my-repo'" in result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def test_install_skill_with_path(tool_context: ToolContext, skills_dir: Path):
|
|
248
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
249
|
+
clone_target = Path(args[0][5]) # git clone --depth 1 url TARGET
|
|
250
|
+
clone_target.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
nested = clone_target / "deep" / "nested" / "humanizer"
|
|
252
|
+
nested.mkdir(parents=True)
|
|
253
|
+
(nested / "SKILL.md").write_text(
|
|
254
|
+
"---\nname: humanizer\ndescription: Humanize text\n---\nMake it human"
|
|
255
|
+
)
|
|
256
|
+
(nested / "extra.txt").write_text("bonus file")
|
|
257
|
+
|
|
258
|
+
class FakeResult:
|
|
259
|
+
returncode = 0
|
|
260
|
+
stderr = ""
|
|
261
|
+
|
|
262
|
+
return FakeResult()
|
|
263
|
+
|
|
264
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
265
|
+
result = await registry.execute(
|
|
266
|
+
"install_skill",
|
|
267
|
+
{
|
|
268
|
+
"url": "https://github.com/user/templates.git",
|
|
269
|
+
"name": "humanizer",
|
|
270
|
+
"path": "deep/nested/humanizer",
|
|
271
|
+
},
|
|
272
|
+
tool_context,
|
|
273
|
+
)
|
|
274
|
+
assert "Installed skill 'humanizer'" in result
|
|
275
|
+
assert "Humanize text" in result
|
|
276
|
+
assert (skills_dir / "humanizer" / "SKILL.md").exists()
|
|
277
|
+
assert (skills_dir / "humanizer" / "extra.txt").exists()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def test_install_skill_with_path_no_skill_md(
|
|
281
|
+
tool_context: ToolContext, skills_dir: Path
|
|
282
|
+
):
|
|
283
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
284
|
+
clone_target = Path(args[0][5])
|
|
285
|
+
clone_target.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
nested = clone_target / "some" / "dir"
|
|
287
|
+
nested.mkdir(parents=True)
|
|
288
|
+
(nested / "README.md").write_text("not a skill")
|
|
289
|
+
|
|
290
|
+
class FakeResult:
|
|
291
|
+
returncode = 0
|
|
292
|
+
stderr = ""
|
|
293
|
+
|
|
294
|
+
return FakeResult()
|
|
295
|
+
|
|
296
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
297
|
+
result = await registry.execute(
|
|
298
|
+
"install_skill",
|
|
299
|
+
{
|
|
300
|
+
"url": "https://github.com/user/templates.git",
|
|
301
|
+
"name": "bad",
|
|
302
|
+
"path": "some/dir",
|
|
303
|
+
},
|
|
304
|
+
tool_context,
|
|
305
|
+
)
|
|
306
|
+
assert "does not contain a SKILL.md" in result
|
|
307
|
+
assert not (skills_dir / "bad").exists()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def test_install_skill_with_path_not_found(
|
|
311
|
+
tool_context: ToolContext, skills_dir: Path
|
|
312
|
+
):
|
|
313
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
314
|
+
clone_target = Path(args[0][5])
|
|
315
|
+
clone_target.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
|
|
317
|
+
class FakeResult:
|
|
318
|
+
returncode = 0
|
|
319
|
+
stderr = ""
|
|
320
|
+
|
|
321
|
+
return FakeResult()
|
|
322
|
+
|
|
323
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
324
|
+
result = await registry.execute(
|
|
325
|
+
"install_skill",
|
|
326
|
+
{
|
|
327
|
+
"url": "https://github.com/user/templates.git",
|
|
328
|
+
"name": "missing",
|
|
329
|
+
"path": "nonexistent/subdir",
|
|
330
|
+
},
|
|
331
|
+
tool_context,
|
|
332
|
+
)
|
|
333
|
+
assert "not found in repository" in result
|
|
334
|
+
assert not (skills_dir / "missing").exists()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def test_install_skill_with_path_derives_name(
|
|
338
|
+
tool_context: ToolContext, skills_dir: Path
|
|
339
|
+
):
|
|
340
|
+
def fake_clone(*args: Any, **_kwargs: Any) -> object:
|
|
341
|
+
clone_target = Path(args[0][5])
|
|
342
|
+
clone_target.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
nested = clone_target / "skills" / "my-tool"
|
|
344
|
+
nested.mkdir(parents=True)
|
|
345
|
+
(nested / "SKILL.md").write_text(
|
|
346
|
+
"---\nname: my-tool\ndescription: A tool\n---\nbody"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
class FakeResult:
|
|
350
|
+
returncode = 0
|
|
351
|
+
stderr = ""
|
|
352
|
+
|
|
353
|
+
return FakeResult()
|
|
354
|
+
|
|
355
|
+
with patch("docketeer_agentskills.tools.subprocess.run", side_effect=fake_clone):
|
|
356
|
+
result = await registry.execute(
|
|
357
|
+
"install_skill",
|
|
358
|
+
{
|
|
359
|
+
"url": "https://github.com/user/templates.git",
|
|
360
|
+
"path": "skills/my-tool",
|
|
361
|
+
},
|
|
362
|
+
tool_context,
|
|
363
|
+
)
|
|
364
|
+
assert "Installed skill 'my-tool'" in result
|
|
365
|
+
assert (skills_dir / "my-tool" / "SKILL.md").exists()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def test_uninstall_skill(tool_context: ToolContext, skills_dir: Path):
|
|
369
|
+
make_skill(skills_dir, name="doomed")
|
|
370
|
+
assert (skills_dir / "doomed").exists()
|
|
371
|
+
result = await registry.execute("uninstall_skill", {"name": "doomed"}, tool_context)
|
|
372
|
+
assert "Removed skill 'doomed'" in result
|
|
373
|
+
assert not (skills_dir / "doomed").exists()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def test_uninstall_skill_not_found(tool_context: ToolContext, skills_dir: Path):
|
|
377
|
+
result = await registry.execute(
|
|
378
|
+
"uninstall_skill", {"name": "nonexistent"}, tool_context
|
|
379
|
+
)
|
|
380
|
+
assert "Skill not found" in result
|