minion-code 0.1.0__py3-none-any.whl → 0.1.1__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.
Files changed (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.1.dist-info/METADATA +475 -0
  98. minion_code-0.1.1.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.1.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.1.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"])
@@ -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"])