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
@@ -5,7 +5,9 @@ Directory listing tool
5
5
  """
6
6
 
7
7
  from pathlib import Path
8
+ from typing import Any, Optional
8
9
  from minion.tools import BaseTool
10
+ from ..utils.output_truncator import truncate_output
9
11
 
10
12
 
11
13
  class LsTool(BaseTool):
@@ -15,7 +17,11 @@ class LsTool(BaseTool):
15
17
  description = "List directory contents"
16
18
  readonly = True # Read-only tool, does not modify system state
17
19
  inputs = {
18
- "path": {"type": "string", "description": "Directory path to list", "nullable": True},
20
+ "path": {
21
+ "type": "string",
22
+ "description": "Directory path to list",
23
+ "nullable": True,
24
+ },
19
25
  "recursive": {
20
26
  "type": "boolean",
21
27
  "description": "Whether to list recursively",
@@ -24,10 +30,23 @@ class LsTool(BaseTool):
24
30
  }
25
31
  output_type = "string"
26
32
 
33
+ def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
34
+ super().__init__(*args, **kwargs)
35
+ self.workdir = Path(workdir) if workdir else None
36
+
37
+ def _resolve_path(self, path: str) -> Path:
38
+ """Resolve path using workdir if path is relative."""
39
+ p = Path(path)
40
+ if p.is_absolute():
41
+ return p
42
+ if self.workdir:
43
+ return self.workdir / p
44
+ return p # Relative to cwd (backward compatible)
45
+
27
46
  def forward(self, path: str = ".", recursive: bool = False) -> str:
28
47
  """List directory contents"""
29
48
  try:
30
- dir_path = Path(path)
49
+ dir_path = self._resolve_path(path)
31
50
  if not dir_path.exists():
32
51
  return f"Error: Path does not exist - {path}"
33
52
 
@@ -59,7 +78,13 @@ class LsTool(BaseTool):
59
78
  else:
60
79
  result += f" Other: {item.name}\n"
61
80
 
62
- return result
81
+ return self.format_for_observation(result)
63
82
 
64
83
  except Exception as e:
65
84
  return f"Error listing directory: {str(e)}"
85
+
86
+ def format_for_observation(self, output: Any) -> str:
87
+ """格式化输出,自动截断过大内容"""
88
+ if isinstance(output, str):
89
+ return truncate_output(output, tool_name=self.name)
90
+ return str(output)
@@ -8,7 +8,11 @@ import time
8
8
  from pathlib import Path
9
9
  from typing import Dict, Any, Optional, List
10
10
  from minion.tools import BaseTool
11
- from minion_code.services import record_file_read, record_file_edit, check_file_freshness
11
+ from minion_code.services import (
12
+ record_file_read,
13
+ record_file_edit,
14
+ check_file_freshness,
15
+ )
12
16
 
13
17
 
14
18
  class MultiEditTool(BaseTool):
@@ -16,15 +20,15 @@ class MultiEditTool(BaseTool):
16
20
  A tool for making multiple edits to a single file atomically.
17
21
  Based on the TypeScript MultiEditTool implementation.
18
22
  """
19
-
23
+
20
24
  name = "multi_edit"
21
25
  description = "A tool for making multiple edits to a single file in one operation"
22
26
  readonly = False
23
-
27
+
24
28
  inputs = {
25
29
  "file_path": {
26
30
  "type": "string",
27
- "description": "The absolute path to the file to modify"
31
+ "description": "The absolute path to the file to modify",
28
32
  },
29
33
  "edits": {
30
34
  "type": "array",
@@ -34,23 +38,23 @@ class MultiEditTool(BaseTool):
34
38
  "properties": {
35
39
  "old_string": {
36
40
  "type": "string",
37
- "description": "The text to replace"
41
+ "description": "The text to replace",
38
42
  },
39
43
  "new_string": {
40
- "type": "string",
41
- "description": "The text to replace it with"
44
+ "type": "string",
45
+ "description": "The text to replace it with",
42
46
  },
43
47
  "replace_all": {
44
48
  "type": "boolean",
45
- "description": "Replace all occurrences of old_string (default: false)"
46
- }
49
+ "description": "Replace all occurrences of old_string (default: false)",
50
+ },
47
51
  },
48
- "required": ["old_string", "new_string"]
49
- }
50
- }
52
+ "required": ["old_string", "new_string"],
53
+ },
54
+ },
51
55
  }
52
56
  output_type = "string"
53
-
57
+
54
58
  def forward(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
55
59
  """Execute multi-edit operation."""
56
60
  try:
@@ -58,35 +62,37 @@ class MultiEditTool(BaseTool):
58
62
  validation_result = self._validate_input(file_path, edits)
59
63
  if not validation_result["valid"]:
60
64
  return f"Error: {validation_result['message']}"
61
-
65
+
62
66
  # Apply all edits atomically
63
67
  result = self._apply_multi_edit(file_path, edits)
64
68
  return result
65
-
69
+
66
70
  except Exception as e:
67
71
  return f"Error during multi-edit: {str(e)}"
68
-
69
- def _validate_input(self, file_path: str, edits: List[Dict[str, Any]]) -> Dict[str, Any]:
72
+
73
+ def _validate_input(
74
+ self, file_path: str, edits: List[Dict[str, Any]]
75
+ ) -> Dict[str, Any]:
70
76
  """Validate input parameters."""
71
-
77
+
72
78
  # Check if we have edits
73
79
  if not edits or len(edits) == 0:
74
80
  return {
75
81
  "valid": False,
76
- "message": "At least one edit operation is required."
82
+ "message": "At least one edit operation is required.",
77
83
  }
78
-
84
+
79
85
  # Resolve absolute path
80
86
  if not os.path.isabs(file_path):
81
87
  file_path = os.path.abspath(file_path)
82
-
88
+
83
89
  # Check if it's a Jupyter notebook
84
- if file_path.endswith('.ipynb'):
90
+ if file_path.endswith(".ipynb"):
85
91
  return {
86
92
  "valid": False,
87
- "message": "File is a Jupyter Notebook. Use NotebookEdit tool instead."
93
+ "message": "File is a Jupyter Notebook. Use NotebookEdit tool instead.",
88
94
  }
89
-
95
+
90
96
  # Handle new file creation
91
97
  if not os.path.exists(file_path):
92
98
  # For new files, ensure parent directory can be created
@@ -97,14 +103,14 @@ class MultiEditTool(BaseTool):
97
103
  except Exception as e:
98
104
  return {
99
105
  "valid": False,
100
- "message": f"Cannot create parent directory: {str(e)}"
106
+ "message": f"Cannot create parent directory: {str(e)}",
101
107
  }
102
-
108
+
103
109
  # For new files, first edit must create the file (empty old_string)
104
110
  if len(edits) == 0 or edits[0].get("old_string", "") != "":
105
111
  return {
106
112
  "valid": False,
107
- "message": "For new files, the first edit must have an empty old_string to create the file content."
113
+ "message": "For new files, the first edit must have an empty old_string to create the file content.",
108
114
  }
109
115
  else:
110
116
  # For existing files, check freshness
@@ -113,64 +119,61 @@ class MultiEditTool(BaseTool):
113
119
  if freshness_result.conflict:
114
120
  return {
115
121
  "valid": False,
116
- "message": "File has been modified since last read. Read it again before editing."
122
+ "message": "File has been modified since last read. Read it again before editing.",
117
123
  }
118
124
  except Exception:
119
125
  # If freshness checking fails, continue with basic validation
120
126
  pass
121
-
127
+
122
128
  # Check if file is binary
123
129
  if self._is_binary_file(file_path):
124
- return {
125
- "valid": False,
126
- "message": "Cannot edit binary files."
127
- }
128
-
130
+ return {"valid": False, "message": "Cannot edit binary files."}
131
+
129
132
  # Pre-validate that all old_strings exist in the file
130
133
  try:
131
- with open(file_path, 'r', encoding='utf-8') as f:
134
+ with open(file_path, "r", encoding="utf-8") as f:
132
135
  current_content = f.read()
133
-
136
+
134
137
  for i, edit in enumerate(edits):
135
138
  old_string = edit.get("old_string", "")
136
139
  if old_string != "" and old_string not in current_content:
137
140
  return {
138
141
  "valid": False,
139
- "message": f"Edit {i + 1}: String to replace not found in file: \"{old_string[:100]}{'...' if len(old_string) > 100 else ''}\""
142
+ "message": f"Edit {i + 1}: String to replace not found in file: \"{old_string[:100]}{'...' if len(old_string) > 100 else ''}\"",
140
143
  }
141
-
144
+
142
145
  except UnicodeDecodeError:
143
146
  return {
144
147
  "valid": False,
145
- "message": "Cannot read file - appears to be binary or has encoding issues."
148
+ "message": "Cannot read file - appears to be binary or has encoding issues.",
146
149
  }
147
-
150
+
148
151
  # Validate each edit
149
152
  for i, edit in enumerate(edits):
150
153
  old_string = edit.get("old_string", "")
151
154
  new_string = edit.get("new_string", "")
152
-
155
+
153
156
  if old_string == new_string:
154
157
  return {
155
158
  "valid": False,
156
- "message": f"Edit {i + 1}: old_string and new_string cannot be the same"
159
+ "message": f"Edit {i + 1}: old_string and new_string cannot be the same",
157
160
  }
158
-
161
+
159
162
  return {"valid": True}
160
-
163
+
161
164
  def _apply_multi_edit(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
162
165
  """Apply all edits to the file atomically."""
163
-
166
+
164
167
  # Resolve absolute path
165
168
  if not os.path.isabs(file_path):
166
169
  file_path = os.path.abspath(file_path)
167
-
170
+
168
171
  # Read current file content (or empty for new files)
169
172
  file_exists = os.path.exists(file_path)
170
-
173
+
171
174
  if file_exists:
172
175
  try:
173
- with open(file_path, 'r', encoding='utf-8') as f:
176
+ with open(file_path, "r", encoding="utf-8") as f:
174
177
  current_content = f.read()
175
178
  except UnicodeDecodeError:
176
179
  return "Error: Cannot read file - appears to be binary or has encoding issues."
@@ -178,94 +181,96 @@ class MultiEditTool(BaseTool):
178
181
  current_content = ""
179
182
  # Ensure parent directory exists
180
183
  os.makedirs(os.path.dirname(file_path), exist_ok=True)
181
-
184
+
182
185
  # Apply all edits sequentially
183
186
  modified_content = current_content
184
187
  applied_edits = []
185
-
188
+
186
189
  for i, edit in enumerate(edits):
187
190
  old_string = edit.get("old_string", "")
188
191
  new_string = edit.get("new_string", "")
189
192
  replace_all = edit.get("replace_all", False)
190
-
193
+
191
194
  try:
192
195
  result = self._apply_content_edit(
193
196
  modified_content, old_string, new_string, replace_all
194
197
  )
195
198
  modified_content = result["new_content"]
196
- applied_edits.append({
197
- "edit_index": i + 1,
198
- "success": True,
199
- "old_string": old_string[:100] + ("..." if len(old_string) > 100 else ""),
200
- "new_string": new_string[:100] + ("..." if len(new_string) > 100 else ""),
201
- "occurrences": result["occurrences"]
202
- })
203
-
199
+ applied_edits.append(
200
+ {
201
+ "edit_index": i + 1,
202
+ "success": True,
203
+ "old_string": old_string[:100]
204
+ + ("..." if len(old_string) > 100 else ""),
205
+ "new_string": new_string[:100]
206
+ + ("..." if len(new_string) > 100 else ""),
207
+ "occurrences": result["occurrences"],
208
+ }
209
+ )
210
+
204
211
  except Exception as e:
205
212
  # If any edit fails, abort the entire operation
206
213
  error_message = str(e)
207
214
  return f"Error in edit {i + 1}: {error_message}"
208
-
215
+
209
216
  # Write the modified content
210
217
  try:
211
- with open(file_path, 'w', encoding='utf-8') as f:
218
+ with open(file_path, "w", encoding="utf-8") as f:
212
219
  f.write(modified_content)
213
220
  except Exception as e:
214
221
  return f"Error writing file: {str(e)}"
215
-
222
+
216
223
  # Record the file edit
217
224
  record_file_edit(file_path, modified_content)
218
-
225
+
219
226
  # Generate result summary
220
227
  operation = "create" if not file_exists else "update"
221
228
  summary = f"Successfully applied {len(edits)} edits to {file_path}"
222
-
229
+
223
230
  # Add details about each edit
224
231
  details = []
225
232
  for edit_info in applied_edits:
226
233
  details.append(
227
234
  f"Edit {edit_info['edit_index']}: Replaced {edit_info['occurrences']} occurrence(s)"
228
235
  )
229
-
236
+
230
237
  if details:
231
238
  summary += "\n" + "\n".join(details)
232
-
239
+
233
240
  return summary
234
-
235
- def _apply_content_edit(self, content: str, old_string: str, new_string: str,
236
- replace_all: bool = False) -> Dict[str, Any]:
241
+
242
+ def _apply_content_edit(
243
+ self, content: str, old_string: str, new_string: str, replace_all: bool = False
244
+ ) -> Dict[str, Any]:
237
245
  """Apply a single content edit."""
238
-
246
+
239
247
  if replace_all:
240
248
  # Replace all occurrences
241
249
  import re
250
+
242
251
  # Escape special regex characters in old_string
243
252
  escaped_old = re.escape(old_string)
244
253
  pattern = re.compile(escaped_old)
245
254
  matches = pattern.findall(content)
246
255
  occurrences = len(matches)
247
256
  new_content = pattern.sub(new_string, content)
248
-
249
- return {
250
- "new_content": new_content,
251
- "occurrences": occurrences
252
- }
257
+
258
+ return {"new_content": new_content, "occurrences": occurrences}
253
259
  else:
254
260
  # Replace single occurrence
255
261
  if old_string in content:
256
- new_content = content.replace(old_string, new_string, 1) # Replace only first occurrence
257
- return {
258
- "new_content": new_content,
259
- "occurrences": 1
260
- }
262
+ new_content = content.replace(
263
+ old_string, new_string, 1
264
+ ) # Replace only first occurrence
265
+ return {"new_content": new_content, "occurrences": 1}
261
266
  else:
262
267
  raise Exception(f"String not found: {old_string[:50]}...")
263
-
268
+
264
269
  def _is_binary_file(self, file_path: str) -> bool:
265
270
  """Check if file is binary."""
266
271
  try:
267
- with open(file_path, 'rb') as f:
272
+ with open(file_path, "rb") as f:
268
273
  chunk = f.read(1024)
269
- return b'\0' in chunk
274
+ return b"\0" in chunk
270
275
  except Exception:
271
- return False
276
+ return False
@@ -7,7 +7,9 @@ Python code execution tool
7
7
  import io
8
8
  import sys
9
9
  from contextlib import redirect_stdout, redirect_stderr
10
+ from typing import Any
10
11
  from minion.tools import BaseTool
12
+ from ..utils.output_truncator import truncate_output
11
13
 
12
14
 
13
15
  class PythonInterpreterTool(BaseTool):
@@ -99,7 +101,13 @@ class PythonInterpreterTool(BaseTool):
99
101
  if not output_parts:
100
102
  output_parts.append("Code executed successfully, no output.")
101
103
 
102
- return "\n".join(output_parts)
104
+ return self.format_for_observation("\n".join(output_parts))
103
105
 
104
106
  except Exception as e:
105
107
  return f"Error executing code: {str(e)}"
108
+
109
+ def format_for_observation(self, output: Any) -> str:
110
+ """格式化输出,自动截断过大内容"""
111
+ if isinstance(output, str):
112
+ return truncate_output(output, tool_name=self.name)
113
+ return str(output)
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Skill Tool - executes skills within the conversation.
5
+
6
+ This tool allows Claude to invoke skills that provide specialized knowledge
7
+ and workflows for specific tasks.
8
+ """
9
+
10
+ from typing import Any, Dict, Optional
11
+ from pydantic import Field
12
+
13
+ from minion.tools import BaseTool
14
+
15
+
16
+ class SkillTool(BaseTool):
17
+ """
18
+ Tool for executing skills within the main conversation.
19
+
20
+ Skills are modular packages that extend Claude's capabilities by providing
21
+ specialized knowledge, workflows, and tools. When a skill is invoked,
22
+ its instructions are loaded into the conversation context.
23
+ """
24
+
25
+ name: str = "Skill"
26
+ description: str = """Execute a skill within the main conversation.
27
+
28
+ Skills are folders of instructions, scripts, and resources that Claude loads
29
+ dynamically to improve performance on specialized tasks.
30
+
31
+ Usage:
32
+ - Invoke skills using this tool with the skill name only (no arguments)
33
+ - When you invoke a skill, its prompt will expand and provide detailed instructions
34
+ - Only use skills listed in <available_skills> in the system prompt
35
+
36
+ Important:
37
+ - Only use skills that are listed as available
38
+ - Do not invoke a skill that is already running
39
+ """
40
+
41
+ parameters: dict = {
42
+ "type": "object",
43
+ "properties": {
44
+ "skill": {
45
+ "type": "string",
46
+ "description": "The skill name to execute (e.g., 'pdf', 'xlsx', 'docx')",
47
+ }
48
+ },
49
+ "required": ["skill"],
50
+ }
51
+
52
+ def __init__(self, **kwargs):
53
+ super().__init__(**kwargs)
54
+ self._registry = None
55
+
56
+ @property
57
+ def registry(self):
58
+ """Get the skill registry, loading skills if needed."""
59
+ if self._registry is None:
60
+ from minion_code.skills import SkillRegistry
61
+ from minion_code.skills.skill_loader import load_skills
62
+
63
+ self._registry = load_skills()
64
+ return self._registry
65
+
66
+ def forward(self, skill: str, **kwargs) -> Dict[str, Any]:
67
+ """
68
+ Execute a skill by loading its instructions into the conversation.
69
+ This is the synchronous entry point required by BaseTool.
70
+
71
+ Args:
72
+ skill: Name of the skill to execute
73
+
74
+ Returns:
75
+ Dict containing the skill prompt and metadata
76
+ """
77
+ return self.execute_skill(skill)
78
+
79
+ def execute_skill(self, skill: str) -> Dict[str, Any]:
80
+ """
81
+ Execute a skill by loading its instructions into the conversation.
82
+
83
+ Args:
84
+ skill: Name of the skill to execute
85
+
86
+ Returns:
87
+ Dict containing the skill prompt and metadata
88
+ """
89
+ # Check if skill exists
90
+ skill_obj = self.registry.get(skill)
91
+
92
+ if skill_obj is None:
93
+ available = [s.name for s in self.registry.list_all()]
94
+ return {
95
+ "success": False,
96
+ "error": f"Unknown skill: {skill}",
97
+ "available_skills": available[:10], # Show first 10
98
+ "hint": "Use one of the available skills listed above",
99
+ }
100
+
101
+ # Get the skill prompt
102
+ prompt = skill_obj.get_prompt()
103
+
104
+ # Build response with skill content
105
+ return {
106
+ "success": True,
107
+ "skill_name": skill_obj.name,
108
+ "skill_description": skill_obj.description,
109
+ "skill_location": skill_obj.location,
110
+ "skill_path": str(
111
+ skill_obj.path
112
+ ), # Absolute path for resolving relative resources
113
+ "prompt": prompt,
114
+ "message": f'The "{skill_obj.name}" skill is loading',
115
+ "allowed_tools": skill_obj.allowed_tools,
116
+ }
117
+
118
+ async def execute(self, skill: str, **kwargs) -> Dict[str, Any]:
119
+ """
120
+ Async wrapper for execute_skill.
121
+
122
+ Args:
123
+ skill: Name of the skill to execute
124
+
125
+ Returns:
126
+ Dict containing the skill prompt and metadata
127
+ """
128
+ return self.execute_skill(skill)
129
+
130
+ def validate_skill(self, skill: str) -> tuple[bool, Optional[str]]:
131
+ """
132
+ Validate that a skill exists and can be executed.
133
+
134
+ Args:
135
+ skill: Name of the skill to validate
136
+
137
+ Returns:
138
+ Tuple of (is_valid, error_message)
139
+ """
140
+ if not skill:
141
+ return False, "Skill name is required"
142
+
143
+ if not self.registry.exists(skill):
144
+ available = [s.name for s in self.registry.list_all()]
145
+ return (
146
+ False,
147
+ f"Unknown skill: {skill}. Available: {', '.join(available[:5])}",
148
+ )
149
+
150
+ return True, None
151
+
152
+ def get_available_skills_prompt(self, char_budget: int = 10000) -> str:
153
+ """
154
+ Generate a prompt listing available skills for the system message.
155
+
156
+ Args:
157
+ char_budget: Maximum characters for skills list
158
+
159
+ Returns:
160
+ Formatted skills prompt
161
+ """
162
+ return self.registry.generate_skills_prompt(char_budget)
163
+
164
+
165
+ def generate_skill_tool_prompt() -> str:
166
+ """
167
+ Generate the complete skill tool prompt including available skills.
168
+
169
+ This is used to generate the skill tool description in the system prompt.
170
+
171
+ Returns:
172
+ Complete skill tool prompt
173
+ """
174
+ from minion_code.skills.skill_loader import load_skills
175
+
176
+ registry = load_skills()
177
+ skills = registry.list_all()
178
+
179
+ if not skills:
180
+ return """Execute a skill within the main conversation.
181
+
182
+ No skills are currently available. Skills can be added to:
183
+ - .claude/skills/ (project-level)
184
+ - ~/.claude/skills/ (user-level)
185
+ - .minion/skills/ (project-level)
186
+ - ~/.minion/skills/ (user-level)
187
+ """
188
+
189
+ skills_xml = "\n".join(skill.to_xml() for skill in skills)
190
+
191
+ return f"""Execute a skill within the main conversation.
192
+
193
+ <skills_instructions>
194
+ When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
195
+
196
+ How to use skills:
197
+ - Invoke skills using this tool with the skill name only (no arguments)
198
+ - When you invoke a skill, you will see <command-message>The "{{name}}" skill is loading</command-message>
199
+ - The skill's prompt will expand and provide detailed instructions on how to complete the task
200
+ - Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
201
+
202
+ Important:
203
+ - Only use skills listed in <available_skills> below
204
+ - Do not invoke a skill that is already running
205
+ </skills_instructions>
206
+
207
+ <available_skills>
208
+ {skills_xml}
209
+ </available_skills>
210
+ """