minion-code 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.2.dist-info/METADATA +476 -0
- minion_code-0.1.2.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
- minion_code-0.1.2.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Simple test script to verify OutputAdapter pattern works correctly"""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import pytest
|
|
6
|
+
from unittest.mock import patch, AsyncMock
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from minion_code.adapters import RichOutputAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MockAgent:
|
|
12
|
+
"""Mock agent for testing"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._conversation_history = [
|
|
16
|
+
{"user_message": "Hello", "agent_response": "Hi there!"},
|
|
17
|
+
{"user_message": "How are you?", "agent_response": "I'm doing well!"},
|
|
18
|
+
{"user_message": "Test message", "agent_response": "Test response"},
|
|
19
|
+
]
|
|
20
|
+
self.tools = []
|
|
21
|
+
|
|
22
|
+
def get_conversation_history(self):
|
|
23
|
+
return self._conversation_history
|
|
24
|
+
|
|
25
|
+
def clear_conversation_history(self):
|
|
26
|
+
self._conversation_history.clear()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_adapter():
|
|
31
|
+
"""Test the adapter pattern"""
|
|
32
|
+
console = Console(force_terminal=False) # Disable terminal features for testing
|
|
33
|
+
|
|
34
|
+
# Test 1: Create adapter
|
|
35
|
+
output_adapter = RichOutputAdapter(console)
|
|
36
|
+
assert output_adapter is not None
|
|
37
|
+
assert output_adapter.console is console
|
|
38
|
+
|
|
39
|
+
# Test 2: Test panel method (should not raise)
|
|
40
|
+
output_adapter.panel(
|
|
41
|
+
"This is a test panel message", title="Test Panel", border_style="green"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Test 3: Test table method
|
|
45
|
+
headers = ["Column 1", "Column 2", "Column 3"]
|
|
46
|
+
rows = [["Data 1", "Data 2", "Data 3"], ["Row 2-1", "Row 2-2", "Row 2-3"]]
|
|
47
|
+
output_adapter.table(headers, rows, title="Test Table")
|
|
48
|
+
|
|
49
|
+
# Test 4: Test text method
|
|
50
|
+
output_adapter.text("This is simple text output")
|
|
51
|
+
|
|
52
|
+
# Test 5: Test command with adapter
|
|
53
|
+
from minion_code.commands.clear_command import ClearCommand
|
|
54
|
+
|
|
55
|
+
mock_agent = MockAgent()
|
|
56
|
+
clear_command = ClearCommand(output_adapter, mock_agent)
|
|
57
|
+
|
|
58
|
+
assert len(mock_agent.get_conversation_history()) == 3
|
|
59
|
+
await clear_command.execute("--force")
|
|
60
|
+
assert len(mock_agent.get_conversation_history()) == 0
|
|
61
|
+
|
|
62
|
+
# Test 6: Test confirm method with mock (avoid stdin)
|
|
63
|
+
with patch("rich.prompt.Confirm.ask", return_value=True):
|
|
64
|
+
result = await output_adapter.confirm(
|
|
65
|
+
"Do you want to continue?",
|
|
66
|
+
title="Test Confirmation",
|
|
67
|
+
ok_text="Yes",
|
|
68
|
+
cancel_text="No",
|
|
69
|
+
)
|
|
70
|
+
assert result is True
|
|
71
|
+
|
|
72
|
+
# Test 7: Test confirm returns False when user declines
|
|
73
|
+
with patch("rich.prompt.Confirm.ask", return_value=False):
|
|
74
|
+
result = await output_adapter.confirm("Continue?")
|
|
75
|
+
assert result is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
asyncio.run(test_adapter())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Tests for FileReadTool with image support and format_for_observation
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
# Import the tool
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
16
|
+
from minion_code.tools.file_read_tool import FileReadTool
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from PIL import Image
|
|
20
|
+
|
|
21
|
+
HAS_PIL = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
HAS_PIL = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestFileReadTool:
|
|
27
|
+
"""Test FileReadTool functionality"""
|
|
28
|
+
|
|
29
|
+
def setup_method(self):
|
|
30
|
+
"""Setup test fixtures"""
|
|
31
|
+
self.tool = FileReadTool()
|
|
32
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
33
|
+
|
|
34
|
+
def teardown_method(self):
|
|
35
|
+
"""Cleanup test fixtures"""
|
|
36
|
+
import shutil
|
|
37
|
+
|
|
38
|
+
if os.path.exists(self.temp_dir):
|
|
39
|
+
shutil.rmtree(self.temp_dir)
|
|
40
|
+
|
|
41
|
+
def test_read_text_file(self):
|
|
42
|
+
"""Test reading a text file"""
|
|
43
|
+
# Create test file
|
|
44
|
+
test_file = os.path.join(self.temp_dir, "test.txt")
|
|
45
|
+
with open(test_file, "w") as f:
|
|
46
|
+
f.write("Line 1\nLine 2\nLine 3\n")
|
|
47
|
+
|
|
48
|
+
# Read file
|
|
49
|
+
result = self.tool.forward(test_file)
|
|
50
|
+
assert isinstance(result, str)
|
|
51
|
+
assert "Line 1" in result
|
|
52
|
+
assert "Line 2" in result
|
|
53
|
+
assert "Line 3" in result
|
|
54
|
+
|
|
55
|
+
def test_format_for_observation_text(self):
|
|
56
|
+
"""Test format_for_observation with text content"""
|
|
57
|
+
# Create test file
|
|
58
|
+
test_file = os.path.join(self.temp_dir, "test.txt")
|
|
59
|
+
with open(test_file, "w") as f:
|
|
60
|
+
f.write("Line 1\nLine 2\nLine 3\n")
|
|
61
|
+
|
|
62
|
+
# Read file
|
|
63
|
+
result = self.tool.forward(test_file)
|
|
64
|
+
|
|
65
|
+
# Format for observation
|
|
66
|
+
formatted = self.tool.format_for_observation(result)
|
|
67
|
+
|
|
68
|
+
# Check that line numbers are present
|
|
69
|
+
assert "1→" in formatted or " 1→" in formatted
|
|
70
|
+
assert "2→" in formatted or " 2→" in formatted
|
|
71
|
+
assert "3→" in formatted or " 3→" in formatted
|
|
72
|
+
assert test_file in formatted
|
|
73
|
+
assert "Total lines:" in formatted
|
|
74
|
+
|
|
75
|
+
def test_format_for_observation_with_offset(self):
|
|
76
|
+
"""Test format_for_observation with offset parameter"""
|
|
77
|
+
# Create test file
|
|
78
|
+
test_file = os.path.join(self.temp_dir, "test.txt")
|
|
79
|
+
with open(test_file, "w") as f:
|
|
80
|
+
f.write("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n")
|
|
81
|
+
|
|
82
|
+
# Read file with offset
|
|
83
|
+
result = self.tool.forward(test_file, offset=2, limit=2)
|
|
84
|
+
|
|
85
|
+
# Format for observation
|
|
86
|
+
formatted = self.tool.format_for_observation(result)
|
|
87
|
+
|
|
88
|
+
# Check that line numbers start from offset
|
|
89
|
+
assert "3→" in formatted or " 3→" in formatted
|
|
90
|
+
assert "4→" in formatted or " 4→" in formatted
|
|
91
|
+
# Should not contain line 1 and 2
|
|
92
|
+
assert "Line 1" not in result
|
|
93
|
+
assert "Line 2" not in result
|
|
94
|
+
|
|
95
|
+
@pytest.mark.skipif(not HAS_PIL, reason="PIL not available")
|
|
96
|
+
def test_read_image_file(self):
|
|
97
|
+
"""Test reading an image file"""
|
|
98
|
+
# Create a simple test image
|
|
99
|
+
test_image = os.path.join(self.temp_dir, "test.png")
|
|
100
|
+
img = Image.new("RGB", (100, 100), color="red")
|
|
101
|
+
img.save(test_image)
|
|
102
|
+
|
|
103
|
+
# Read image
|
|
104
|
+
result = self.tool.forward(test_image)
|
|
105
|
+
|
|
106
|
+
# Should return PIL Image object
|
|
107
|
+
assert isinstance(result, Image.Image)
|
|
108
|
+
assert result.size == (100, 100)
|
|
109
|
+
|
|
110
|
+
@pytest.mark.skipif(not HAS_PIL, reason="PIL not available")
|
|
111
|
+
def test_format_for_observation_image(self):
|
|
112
|
+
"""Test format_for_observation with image"""
|
|
113
|
+
# Create a simple test image
|
|
114
|
+
test_image = os.path.join(self.temp_dir, "test.png")
|
|
115
|
+
img = Image.new("RGB", (100, 100), color="blue")
|
|
116
|
+
img.save(test_image)
|
|
117
|
+
|
|
118
|
+
# Read image
|
|
119
|
+
result = self.tool.forward(test_image)
|
|
120
|
+
|
|
121
|
+
# Format for observation
|
|
122
|
+
formatted = self.tool.format_for_observation(result)
|
|
123
|
+
|
|
124
|
+
# Check that it contains base64 encoding
|
|
125
|
+
assert "base64" in formatted.lower()
|
|
126
|
+
assert "100x100" in formatted
|
|
127
|
+
assert test_image in formatted
|
|
128
|
+
assert "Image file:" in formatted
|
|
129
|
+
|
|
130
|
+
def test_nonexistent_file(self):
|
|
131
|
+
"""Test reading a nonexistent file"""
|
|
132
|
+
result = self.tool.forward("/nonexistent/file.txt")
|
|
133
|
+
assert "Error" in result
|
|
134
|
+
assert "does not exist" in result
|
|
135
|
+
|
|
136
|
+
def test_error_format_for_observation(self):
|
|
137
|
+
"""Test that errors are passed through format_for_observation"""
|
|
138
|
+
result = self.tool.forward("/nonexistent/file.txt")
|
|
139
|
+
formatted = self.tool.format_for_observation(result)
|
|
140
|
+
assert "Error" in formatted
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
pytest.main([__file__, "-v"])
|
tests/test_readonly_tools.py
CHANGED
|
@@ -18,7 +18,6 @@ from minion_code.tools import (
|
|
|
18
18
|
WikipediaSearchTool,
|
|
19
19
|
VisitWebpageTool,
|
|
20
20
|
UserInputTool,
|
|
21
|
-
|
|
22
21
|
)
|
|
23
22
|
|
|
24
23
|
|
|
@@ -75,7 +74,6 @@ def test_visit_webpage_tool():
|
|
|
75
74
|
assert "https://www.example.com" in result
|
|
76
75
|
|
|
77
76
|
|
|
78
|
-
|
|
79
77
|
def test_tool_inheritance():
|
|
80
78
|
"""测试所有工具都正确继承了 BaseTool"""
|
|
81
79
|
from minion.tools import BaseTool
|
tests/test_skills.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Tests for the skills system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
from minion_code.skills.skill import Skill
|
|
13
|
+
from minion_code.skills.skill_registry import (
|
|
14
|
+
SkillRegistry,
|
|
15
|
+
get_skill_registry,
|
|
16
|
+
reset_skill_registry,
|
|
17
|
+
)
|
|
18
|
+
from minion_code.skills.skill_loader import SkillLoader
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestSkill:
|
|
22
|
+
"""Tests for the Skill class."""
|
|
23
|
+
|
|
24
|
+
def test_parse_frontmatter_basic(self):
|
|
25
|
+
"""Test parsing basic YAML frontmatter."""
|
|
26
|
+
content = """---
|
|
27
|
+
name: test-skill
|
|
28
|
+
description: A test skill for testing purposes
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Test Skill Instructions
|
|
32
|
+
|
|
33
|
+
This is the skill content.
|
|
34
|
+
"""
|
|
35
|
+
frontmatter, body = Skill._parse_frontmatter(content)
|
|
36
|
+
|
|
37
|
+
assert frontmatter["name"] == "test-skill"
|
|
38
|
+
assert frontmatter["description"] == "A test skill for testing purposes"
|
|
39
|
+
assert "# Test Skill Instructions" in body
|
|
40
|
+
|
|
41
|
+
def test_parse_frontmatter_with_optional_fields(self):
|
|
42
|
+
"""Test parsing frontmatter with optional fields."""
|
|
43
|
+
content = """---
|
|
44
|
+
name: advanced-skill
|
|
45
|
+
description: An advanced skill
|
|
46
|
+
license: MIT
|
|
47
|
+
allowed-tools:
|
|
48
|
+
- Bash
|
|
49
|
+
- Read
|
|
50
|
+
metadata:
|
|
51
|
+
author: test
|
|
52
|
+
version: "1.0"
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
Instructions here.
|
|
56
|
+
"""
|
|
57
|
+
frontmatter, body = Skill._parse_frontmatter(content)
|
|
58
|
+
|
|
59
|
+
assert frontmatter["name"] == "advanced-skill"
|
|
60
|
+
assert frontmatter["license"] == "MIT"
|
|
61
|
+
assert frontmatter["allowed-tools"] == ["Bash", "Read"]
|
|
62
|
+
assert frontmatter["metadata"]["author"] == "test"
|
|
63
|
+
|
|
64
|
+
def test_parse_frontmatter_no_frontmatter(self):
|
|
65
|
+
"""Test parsing content without frontmatter."""
|
|
66
|
+
content = "# Just regular markdown\n\nNo frontmatter here."
|
|
67
|
+
frontmatter, body = Skill._parse_frontmatter(content)
|
|
68
|
+
|
|
69
|
+
assert frontmatter == {}
|
|
70
|
+
assert body == content
|
|
71
|
+
|
|
72
|
+
def test_from_skill_md(self, tmp_path):
|
|
73
|
+
"""Test creating Skill from SKILL.md file."""
|
|
74
|
+
# Create a temporary skill directory
|
|
75
|
+
skill_dir = tmp_path / "my-skill"
|
|
76
|
+
skill_dir.mkdir()
|
|
77
|
+
|
|
78
|
+
skill_md = skill_dir / "SKILL.md"
|
|
79
|
+
skill_md.write_text(
|
|
80
|
+
"""---
|
|
81
|
+
name: my-skill
|
|
82
|
+
description: My custom skill for testing
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# My Skill
|
|
86
|
+
|
|
87
|
+
Follow these instructions to use my skill.
|
|
88
|
+
|
|
89
|
+
## Steps
|
|
90
|
+
1. Do this
|
|
91
|
+
2. Then that
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
skill = Skill.from_skill_md(skill_md, location="project")
|
|
96
|
+
|
|
97
|
+
assert skill is not None
|
|
98
|
+
assert skill.name == "my-skill"
|
|
99
|
+
assert skill.description == "My custom skill for testing"
|
|
100
|
+
assert skill.location == "project"
|
|
101
|
+
assert "# My Skill" in skill.content
|
|
102
|
+
assert skill.path == skill_dir
|
|
103
|
+
|
|
104
|
+
def test_from_skill_md_missing_required_fields(self, tmp_path):
|
|
105
|
+
"""Test that skills without required fields return None."""
|
|
106
|
+
skill_dir = tmp_path / "bad-skill"
|
|
107
|
+
skill_dir.mkdir()
|
|
108
|
+
|
|
109
|
+
skill_md = skill_dir / "SKILL.md"
|
|
110
|
+
skill_md.write_text(
|
|
111
|
+
"""---
|
|
112
|
+
name: incomplete-skill
|
|
113
|
+
# Missing description!
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
Some content.
|
|
117
|
+
"""
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
skill = Skill.from_skill_md(skill_md)
|
|
121
|
+
assert skill is None
|
|
122
|
+
|
|
123
|
+
def test_to_xml(self):
|
|
124
|
+
"""Test XML output format."""
|
|
125
|
+
skill = Skill(
|
|
126
|
+
name="xml-test",
|
|
127
|
+
description="Test XML output",
|
|
128
|
+
content="Content here",
|
|
129
|
+
path=Path("/tmp/test"),
|
|
130
|
+
location="user",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
xml = skill.to_xml()
|
|
134
|
+
|
|
135
|
+
assert "<skill>" in xml
|
|
136
|
+
assert "<name>xml-test</name>" in xml
|
|
137
|
+
assert "<description>Test XML output</description>" in xml
|
|
138
|
+
assert "<location>user</location>" in xml
|
|
139
|
+
|
|
140
|
+
def test_get_prompt_includes_base_directory(self, tmp_path):
|
|
141
|
+
"""Test that get_prompt includes the base directory header."""
|
|
142
|
+
skill_path = tmp_path / "my-skill"
|
|
143
|
+
skill = Skill(
|
|
144
|
+
name="test-skill",
|
|
145
|
+
description="Test skill",
|
|
146
|
+
content="# Instructions\n\nDo this.",
|
|
147
|
+
path=skill_path,
|
|
148
|
+
location="project",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
prompt = skill.get_prompt()
|
|
152
|
+
|
|
153
|
+
assert "Loading: test-skill" in prompt
|
|
154
|
+
assert f"Base directory: {skill_path}" in prompt
|
|
155
|
+
assert "# Instructions" in prompt
|
|
156
|
+
assert "Do this." in prompt
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestSkillRegistry:
|
|
160
|
+
"""Tests for the SkillRegistry class."""
|
|
161
|
+
|
|
162
|
+
def setup_method(self):
|
|
163
|
+
"""Reset registry before each test."""
|
|
164
|
+
reset_skill_registry()
|
|
165
|
+
|
|
166
|
+
def test_register_and_get(self):
|
|
167
|
+
"""Test registering and retrieving skills."""
|
|
168
|
+
registry = SkillRegistry()
|
|
169
|
+
|
|
170
|
+
skill = Skill(
|
|
171
|
+
name="test",
|
|
172
|
+
description="Test skill",
|
|
173
|
+
content="Content",
|
|
174
|
+
path=Path("/tmp"),
|
|
175
|
+
location="project",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
result = registry.register(skill)
|
|
179
|
+
assert result is True
|
|
180
|
+
assert registry.get("test") == skill
|
|
181
|
+
|
|
182
|
+
def test_priority_project_over_user(self):
|
|
183
|
+
"""Test that project skills override user skills."""
|
|
184
|
+
registry = SkillRegistry()
|
|
185
|
+
|
|
186
|
+
user_skill = Skill(
|
|
187
|
+
name="same-name",
|
|
188
|
+
description="User version",
|
|
189
|
+
content="User content",
|
|
190
|
+
path=Path("/home/user"),
|
|
191
|
+
location="user",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
project_skill = Skill(
|
|
195
|
+
name="same-name",
|
|
196
|
+
description="Project version",
|
|
197
|
+
content="Project content",
|
|
198
|
+
path=Path("/project"),
|
|
199
|
+
location="project",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Register user skill first
|
|
203
|
+
registry.register(user_skill)
|
|
204
|
+
# Then register project skill - should override
|
|
205
|
+
registry.register(project_skill)
|
|
206
|
+
|
|
207
|
+
retrieved = registry.get("same-name")
|
|
208
|
+
assert retrieved.description == "Project version"
|
|
209
|
+
|
|
210
|
+
def test_priority_user_does_not_override_project(self):
|
|
211
|
+
"""Test that user skills don't override project skills."""
|
|
212
|
+
registry = SkillRegistry()
|
|
213
|
+
|
|
214
|
+
project_skill = Skill(
|
|
215
|
+
name="same-name",
|
|
216
|
+
description="Project version",
|
|
217
|
+
content="Project content",
|
|
218
|
+
path=Path("/project"),
|
|
219
|
+
location="project",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
user_skill = Skill(
|
|
223
|
+
name="same-name",
|
|
224
|
+
description="User version",
|
|
225
|
+
content="User content",
|
|
226
|
+
path=Path("/home/user"),
|
|
227
|
+
location="user",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Register project skill first
|
|
231
|
+
registry.register(project_skill)
|
|
232
|
+
# Try to register user skill - should be skipped
|
|
233
|
+
result = registry.register(user_skill)
|
|
234
|
+
|
|
235
|
+
assert result is False
|
|
236
|
+
retrieved = registry.get("same-name")
|
|
237
|
+
assert retrieved.description == "Project version"
|
|
238
|
+
|
|
239
|
+
def test_list_all(self):
|
|
240
|
+
"""Test listing all skills."""
|
|
241
|
+
registry = SkillRegistry()
|
|
242
|
+
|
|
243
|
+
for i in range(3):
|
|
244
|
+
skill = Skill(
|
|
245
|
+
name=f"skill-{i}",
|
|
246
|
+
description=f"Skill {i}",
|
|
247
|
+
content="Content",
|
|
248
|
+
path=Path("/tmp"),
|
|
249
|
+
location="project",
|
|
250
|
+
)
|
|
251
|
+
registry.register(skill)
|
|
252
|
+
|
|
253
|
+
all_skills = registry.list_all()
|
|
254
|
+
assert len(all_skills) == 3
|
|
255
|
+
|
|
256
|
+
def test_generate_skills_prompt(self):
|
|
257
|
+
"""Test generating skills prompt."""
|
|
258
|
+
registry = SkillRegistry()
|
|
259
|
+
|
|
260
|
+
skill = Skill(
|
|
261
|
+
name="prompt-test",
|
|
262
|
+
description="Test prompt generation",
|
|
263
|
+
content="Content",
|
|
264
|
+
path=Path("/tmp"),
|
|
265
|
+
location="project",
|
|
266
|
+
)
|
|
267
|
+
registry.register(skill)
|
|
268
|
+
|
|
269
|
+
prompt = registry.generate_skills_prompt()
|
|
270
|
+
|
|
271
|
+
assert "<available_skills>" in prompt
|
|
272
|
+
assert "<name>prompt-test</name>" in prompt
|
|
273
|
+
|
|
274
|
+
def test_generate_skills_prompt_respects_budget(self):
|
|
275
|
+
"""Test that prompt generation respects character budget."""
|
|
276
|
+
registry = SkillRegistry()
|
|
277
|
+
|
|
278
|
+
# Add many skills
|
|
279
|
+
for i in range(100):
|
|
280
|
+
skill = Skill(
|
|
281
|
+
name=f"skill-{i:03d}",
|
|
282
|
+
description="A " * 50, # Long description
|
|
283
|
+
content="Content",
|
|
284
|
+
path=Path("/tmp"),
|
|
285
|
+
location="project",
|
|
286
|
+
)
|
|
287
|
+
registry.register(skill)
|
|
288
|
+
|
|
289
|
+
# Small budget should limit output
|
|
290
|
+
prompt = registry.generate_skills_prompt(char_budget=500)
|
|
291
|
+
assert len(prompt) < 1000 # Should be limited
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TestSkillLoader:
|
|
295
|
+
"""Tests for the SkillLoader class."""
|
|
296
|
+
|
|
297
|
+
def test_get_search_paths(self, tmp_path):
|
|
298
|
+
"""Test search path generation."""
|
|
299
|
+
loader = SkillLoader(project_root=tmp_path)
|
|
300
|
+
paths = loader.get_search_paths()
|
|
301
|
+
|
|
302
|
+
# Should have project and user paths
|
|
303
|
+
locations = [loc for _, loc in paths]
|
|
304
|
+
assert "project" in locations
|
|
305
|
+
assert "user" in locations
|
|
306
|
+
|
|
307
|
+
def test_discover_skills(self, tmp_path):
|
|
308
|
+
"""Test skill discovery in directory."""
|
|
309
|
+
# Create skills directory structure
|
|
310
|
+
skills_dir = tmp_path / ".claude" / "skills"
|
|
311
|
+
skills_dir.mkdir(parents=True)
|
|
312
|
+
|
|
313
|
+
# Create two skill directories
|
|
314
|
+
for name in ["skill-a", "skill-b"]:
|
|
315
|
+
skill_dir = skills_dir / name
|
|
316
|
+
skill_dir.mkdir()
|
|
317
|
+
(skill_dir / "SKILL.md").write_text(
|
|
318
|
+
f"""---
|
|
319
|
+
name: {name}
|
|
320
|
+
description: Test skill {name}
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
Instructions for {name}.
|
|
324
|
+
"""
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
loader = SkillLoader(project_root=tmp_path)
|
|
328
|
+
skill_files = loader.discover_skills(skills_dir)
|
|
329
|
+
|
|
330
|
+
assert len(skill_files) == 2
|
|
331
|
+
names = [f.parent.name for f in skill_files]
|
|
332
|
+
assert "skill-a" in names
|
|
333
|
+
assert "skill-b" in names
|
|
334
|
+
|
|
335
|
+
def test_discover_nested_skills(self, tmp_path):
|
|
336
|
+
"""Test discovery of nested skills (like document-skills/pdf)."""
|
|
337
|
+
skills_dir = tmp_path / ".claude" / "skills"
|
|
338
|
+
nested_dir = skills_dir / "document-skills" / "pdf"
|
|
339
|
+
nested_dir.mkdir(parents=True)
|
|
340
|
+
|
|
341
|
+
(nested_dir / "SKILL.md").write_text(
|
|
342
|
+
"""---
|
|
343
|
+
name: pdf
|
|
344
|
+
description: PDF processing skill
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
PDF instructions.
|
|
348
|
+
"""
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
loader = SkillLoader(project_root=tmp_path)
|
|
352
|
+
skill_files = loader.discover_skills(skills_dir)
|
|
353
|
+
|
|
354
|
+
assert len(skill_files) == 1
|
|
355
|
+
assert skill_files[0].parent.name == "pdf"
|
|
356
|
+
|
|
357
|
+
def test_load_all(self, tmp_path):
|
|
358
|
+
"""Test loading all skills."""
|
|
359
|
+
# Create skills directory
|
|
360
|
+
skills_dir = tmp_path / ".claude" / "skills" / "test-skill"
|
|
361
|
+
skills_dir.mkdir(parents=True)
|
|
362
|
+
|
|
363
|
+
(skills_dir / "SKILL.md").write_text(
|
|
364
|
+
"""---
|
|
365
|
+
name: test-skill
|
|
366
|
+
description: A test skill
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
Test instructions.
|
|
370
|
+
"""
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
reset_skill_registry()
|
|
374
|
+
loader = SkillLoader(project_root=tmp_path)
|
|
375
|
+
registry = loader.load_all()
|
|
376
|
+
|
|
377
|
+
assert len(registry) >= 1
|
|
378
|
+
assert registry.exists("test-skill")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class TestIntegration:
|
|
382
|
+
"""Integration tests for the skills system."""
|
|
383
|
+
|
|
384
|
+
def test_full_workflow(self, tmp_path):
|
|
385
|
+
"""Test complete workflow from skill file to execution."""
|
|
386
|
+
# Setup
|
|
387
|
+
reset_skill_registry()
|
|
388
|
+
|
|
389
|
+
# Create skill
|
|
390
|
+
skills_dir = tmp_path / ".minion" / "skills" / "my-workflow"
|
|
391
|
+
skills_dir.mkdir(parents=True)
|
|
392
|
+
|
|
393
|
+
(skills_dir / "SKILL.md").write_text(
|
|
394
|
+
"""---
|
|
395
|
+
name: my-workflow
|
|
396
|
+
description: Custom workflow for data processing
|
|
397
|
+
allowed-tools:
|
|
398
|
+
- Bash
|
|
399
|
+
- Read
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
# My Workflow
|
|
403
|
+
|
|
404
|
+
This skill helps you process data efficiently.
|
|
405
|
+
|
|
406
|
+
## Steps
|
|
407
|
+
|
|
408
|
+
1. Read the input file
|
|
409
|
+
2. Process the data
|
|
410
|
+
3. Write the output
|
|
411
|
+
|
|
412
|
+
## Example
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
cat input.txt | process | tee output.txt
|
|
416
|
+
```
|
|
417
|
+
"""
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Load
|
|
421
|
+
loader = SkillLoader(project_root=tmp_path)
|
|
422
|
+
registry = loader.load_all()
|
|
423
|
+
|
|
424
|
+
# Verify
|
|
425
|
+
skill = registry.get("my-workflow")
|
|
426
|
+
assert skill is not None
|
|
427
|
+
assert skill.name == "my-workflow"
|
|
428
|
+
assert "Bash" in skill.allowed_tools
|
|
429
|
+
assert "# My Workflow" in skill.get_prompt()
|
|
430
|
+
|
|
431
|
+
# Test XML output
|
|
432
|
+
xml = skill.to_xml()
|
|
433
|
+
assert "my-workflow" in xml
|
|
434
|
+
|
|
435
|
+
# Test prompt generation
|
|
436
|
+
prompt = registry.generate_skills_prompt()
|
|
437
|
+
assert "my-workflow" in prompt
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
if __name__ == "__main__":
|
|
441
|
+
pytest.main([__file__, "-v"])
|