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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
@@ -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,3 @@
1
+ """Agent Skills plugin for Docketeer."""
2
+
3
+ import docketeer_agentskills.tools as _tools # noqa: F401
@@ -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