mita-code 0.1.0__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.
- mita/__init__.py +5 -0
- mita/__main__.py +5 -0
- mita/agent/__init__.py +1 -0
- mita/agent/context.py +43 -0
- mita/agent/conversation.py +101 -0
- mita/agent/loop.py +594 -0
- mita/agent/system_prompt.py +75 -0
- mita/cli.py +940 -0
- mita/config/__init__.py +6 -0
- mita/config/defaults.py +41 -0
- mita/config/loader.py +53 -0
- mita/config/schema.py +131 -0
- mita/hooks/__init__.py +1 -0
- mita/hooks/manager.py +94 -0
- mita/hooks/runner.py +145 -0
- mita/index/__init__.py +1 -0
- mita/index/embeddings.py +49 -0
- mita/index/manager.py +170 -0
- mita/index/parser.py +331 -0
- mita/index/retriever.py +53 -0
- mita/index/store.py +143 -0
- mita/llm/__init__.py +1 -0
- mita/llm/client.py +86 -0
- mita/llm/instructor.py +80 -0
- mita/llm/streaming.py +58 -0
- mita/memory/__init__.py +6 -0
- mita/memory/discovery.py +61 -0
- mita/memory/loader.py +76 -0
- mita/memory/manager.py +117 -0
- mita/models/__init__.py +13 -0
- mita/models/hardware.py +289 -0
- mita/models/manager.py +268 -0
- mita/models/ollama_client.py +104 -0
- mita/models/recommender.py +88 -0
- mita/models/registry.py +167 -0
- mita/models/server.py +262 -0
- mita/plugins/__init__.py +1 -0
- mita/plugins/client.py +152 -0
- mita/plugins/manager.py +210 -0
- mita/py.typed +0 -0
- mita/skills/__init__.py +1 -0
- mita/skills/executor.py +84 -0
- mita/skills/loader.py +117 -0
- mita/skills/manager.py +129 -0
- mita/tools/__init__.py +1 -0
- mita/tools/builtins/__init__.py +28 -0
- mita/tools/builtins/file_edit.py +71 -0
- mita/tools/builtins/file_read.py +74 -0
- mita/tools/builtins/file_write.py +42 -0
- mita/tools/builtins/git.py +112 -0
- mita/tools/builtins/glob_tool.py +67 -0
- mita/tools/builtins/grep_tool.py +93 -0
- mita/tools/builtins/shell.py +83 -0
- mita/tools/executor.py +80 -0
- mita/tools/registry.py +69 -0
- mita/tools/safety.py +91 -0
- mita/tools/schema.py +87 -0
- mita/ui/__init__.py +1 -0
- mita/ui/display.py +139 -0
- mita/ui/repl.py +88 -0
- mita/ui/spinner.py +48 -0
- mita/ui/theme.py +23 -0
- mita_code-0.1.0.dist-info/METADATA +227 -0
- mita_code-0.1.0.dist-info/RECORD +67 -0
- mita_code-0.1.0.dist-info/WHEEL +4 -0
- mita_code-0.1.0.dist-info/entry_points.txt +3 -0
- mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
mita/plugins/manager.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Plugin manager — manages all MCP plugin connections and tool registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from mita.config.schema import PluginDefinition
|
|
10
|
+
from mita.plugins.client import MCPPluginClient
|
|
11
|
+
from mita.tools.registry import ToolHandler, ToolRegistry
|
|
12
|
+
from mita.tools.schema import ToolDefinition, ToolParameter, ToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _schema_to_parameters(input_schema: dict[str, Any]) -> list[ToolParameter]:
|
|
16
|
+
"""Convert a JSON Schema inputSchema to a list of ToolParameter."""
|
|
17
|
+
properties = input_schema.get("properties", {})
|
|
18
|
+
required = set(input_schema.get("required", []))
|
|
19
|
+
params: list[ToolParameter] = []
|
|
20
|
+
|
|
21
|
+
for name, prop in properties.items():
|
|
22
|
+
params.append(
|
|
23
|
+
ToolParameter(
|
|
24
|
+
name=name,
|
|
25
|
+
type=prop.get("type", "string"),
|
|
26
|
+
description=prop.get("description", ""),
|
|
27
|
+
required=name in required,
|
|
28
|
+
default=prop.get("default"),
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
return params
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PluginManager:
|
|
35
|
+
"""Manages MCP plugin connections and exposes their tools."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, plugins: list[PluginDefinition]) -> None:
|
|
38
|
+
self._plugins = plugins
|
|
39
|
+
self._clients: dict[str, MCPPluginClient] = {}
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def plugin_names(self) -> list[str]:
|
|
43
|
+
return [p.name for p in self._plugins]
|
|
44
|
+
|
|
45
|
+
def get_client(self, name: str) -> MCPPluginClient | None:
|
|
46
|
+
return self._clients.get(name)
|
|
47
|
+
|
|
48
|
+
async def start_all(self, console: Console | None = None) -> list[str]:
|
|
49
|
+
"""Start all configured plugins. Returns list of successfully started plugin names."""
|
|
50
|
+
started: list[str] = []
|
|
51
|
+
for plugin in self._plugins:
|
|
52
|
+
try:
|
|
53
|
+
client = MCPPluginClient(plugin)
|
|
54
|
+
await client.connect()
|
|
55
|
+
self._clients[plugin.name] = client
|
|
56
|
+
started.append(plugin.name)
|
|
57
|
+
except (ConnectionError, OSError, TimeoutError, ValueError, RuntimeError) as e:
|
|
58
|
+
if console:
|
|
59
|
+
console.print(f"[yellow]Plugin '{plugin.name}' failed to start: {e}[/yellow]")
|
|
60
|
+
return started
|
|
61
|
+
|
|
62
|
+
async def stop_all(self) -> None:
|
|
63
|
+
"""Disconnect all running plugins."""
|
|
64
|
+
for client in self._clients.values():
|
|
65
|
+
try:
|
|
66
|
+
await client.disconnect()
|
|
67
|
+
except (ConnectionError, OSError):
|
|
68
|
+
pass
|
|
69
|
+
self._clients.clear()
|
|
70
|
+
|
|
71
|
+
async def start_plugin(self, name: str, console: Console | None = None) -> bool:
|
|
72
|
+
"""Start a single plugin by name."""
|
|
73
|
+
plugin = next((p for p in self._plugins if p.name == name), None)
|
|
74
|
+
if plugin is None:
|
|
75
|
+
if console:
|
|
76
|
+
console.print(f"[red]Plugin '{name}' not found in configuration.[/red]")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
client = MCPPluginClient(plugin)
|
|
81
|
+
await client.connect()
|
|
82
|
+
self._clients[name] = client
|
|
83
|
+
return True
|
|
84
|
+
except (ConnectionError, OSError, TimeoutError, ValueError, RuntimeError) as e:
|
|
85
|
+
if console:
|
|
86
|
+
console.print(f"[red]Plugin '{name}' failed to start: {e}[/red]")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
async def stop_plugin(self, name: str) -> None:
|
|
90
|
+
"""Stop a single plugin by name."""
|
|
91
|
+
client = self._clients.pop(name, None)
|
|
92
|
+
if client is not None:
|
|
93
|
+
await client.disconnect()
|
|
94
|
+
|
|
95
|
+
async def list_tools(self, name: str | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
96
|
+
"""List tools from connected plugins.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
name: If provided, list tools from this plugin only.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict mapping plugin name to list of tool dicts.
|
|
103
|
+
"""
|
|
104
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
105
|
+
clients = {name: self._clients[name]} if name and name in self._clients else self._clients
|
|
106
|
+
for pname, client in clients.items():
|
|
107
|
+
try:
|
|
108
|
+
result[pname] = await client.list_tools()
|
|
109
|
+
except (ConnectionError, OSError, RuntimeError):
|
|
110
|
+
result[pname] = []
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
def register_tools(self, registry: ToolRegistry) -> int:
|
|
114
|
+
"""Register all MCP plugin tools into a ToolRegistry.
|
|
115
|
+
|
|
116
|
+
Must be called after start_all(). Returns the number of tools registered.
|
|
117
|
+
"""
|
|
118
|
+
import asyncio
|
|
119
|
+
|
|
120
|
+
loop = asyncio.get_event_loop()
|
|
121
|
+
count = 0
|
|
122
|
+
|
|
123
|
+
for pname, client in self._clients.items():
|
|
124
|
+
try:
|
|
125
|
+
tools = loop.run_until_complete(client.list_tools())
|
|
126
|
+
except (ConnectionError, OSError, RuntimeError):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
for tool in tools:
|
|
130
|
+
tool_name = f"mcp:{pname}/{tool['name']}"
|
|
131
|
+
definition = ToolDefinition(
|
|
132
|
+
name=tool_name,
|
|
133
|
+
description=tool["description"],
|
|
134
|
+
parameters=_schema_to_parameters(tool.get("inputSchema", {})),
|
|
135
|
+
source=f"mcp:{pname}",
|
|
136
|
+
)
|
|
137
|
+
handler = _make_mcp_handler(client, tool["name"])
|
|
138
|
+
registry.register(definition, handler)
|
|
139
|
+
count += 1
|
|
140
|
+
|
|
141
|
+
return count
|
|
142
|
+
|
|
143
|
+
async def register_tools_async(self, registry: ToolRegistry) -> int:
|
|
144
|
+
"""Async version of register_tools. Returns the number of tools registered."""
|
|
145
|
+
count = 0
|
|
146
|
+
|
|
147
|
+
for pname, client in self._clients.items():
|
|
148
|
+
try:
|
|
149
|
+
tools = await client.list_tools()
|
|
150
|
+
except (ConnectionError, OSError, RuntimeError):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
for tool in tools:
|
|
154
|
+
tool_name = f"mcp:{pname}/{tool['name']}"
|
|
155
|
+
definition = ToolDefinition(
|
|
156
|
+
name=tool_name,
|
|
157
|
+
description=tool["description"],
|
|
158
|
+
parameters=_schema_to_parameters(tool.get("inputSchema", {})),
|
|
159
|
+
source=f"mcp:{pname}",
|
|
160
|
+
)
|
|
161
|
+
handler = _make_mcp_handler(client, tool["name"])
|
|
162
|
+
registry.register(definition, handler)
|
|
163
|
+
count += 1
|
|
164
|
+
|
|
165
|
+
return count
|
|
166
|
+
|
|
167
|
+
async def test_plugin(self, name: str) -> dict[str, Any]:
|
|
168
|
+
"""Test plugin connectivity. Returns status dict."""
|
|
169
|
+
client = self._clients.get(name)
|
|
170
|
+
if client is None:
|
|
171
|
+
return {"name": name, "connected": False, "error": "Not started"}
|
|
172
|
+
|
|
173
|
+
alive = await client.ping()
|
|
174
|
+
if not alive:
|
|
175
|
+
return {"name": name, "connected": True, "ping": False, "error": "Ping failed"}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
tools = await client.list_tools()
|
|
179
|
+
return {
|
|
180
|
+
"name": name,
|
|
181
|
+
"connected": True,
|
|
182
|
+
"ping": True,
|
|
183
|
+
"tools": len(tools),
|
|
184
|
+
"tool_names": [t["name"] for t in tools],
|
|
185
|
+
}
|
|
186
|
+
except (ConnectionError, OSError, RuntimeError) as e:
|
|
187
|
+
return {"name": name, "connected": True, "ping": True, "error": str(e)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _make_mcp_handler(client: MCPPluginClient, remote_tool_name: str) -> ToolHandler:
|
|
191
|
+
"""Create a tool handler that dispatches to an MCP plugin."""
|
|
192
|
+
|
|
193
|
+
async def handler(args: dict[str, Any]) -> ToolResult:
|
|
194
|
+
try:
|
|
195
|
+
output = await client.call_tool(remote_tool_name, args)
|
|
196
|
+
return ToolResult(tool_call_id="", success=True, output=output)
|
|
197
|
+
except TimeoutError:
|
|
198
|
+
return ToolResult(
|
|
199
|
+
tool_call_id="",
|
|
200
|
+
success=False,
|
|
201
|
+
error=f"MCP tool '{remote_tool_name}' timed out",
|
|
202
|
+
)
|
|
203
|
+
except (ConnectionError, OSError, RuntimeError) as e:
|
|
204
|
+
return ToolResult(
|
|
205
|
+
tool_call_id="",
|
|
206
|
+
success=False,
|
|
207
|
+
error=f"MCP tool '{remote_tool_name}' failed: {e}",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return handler
|
mita/py.typed
ADDED
|
File without changes
|
mita/skills/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Skills system: reusable parameterized prompt templates."""
|
mita/skills/executor.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Render skill templates with argument substitution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mita.skills.loader import Skill
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_skill(skill: Skill, user_input: str) -> str:
|
|
11
|
+
"""Render a skill template, substituting arguments from user input.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
skill: The skill to render.
|
|
15
|
+
user_input: The raw user input (e.g., "/commit -m 'fix bug'").
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The rendered prompt string.
|
|
19
|
+
"""
|
|
20
|
+
args = _parse_args(user_input, skill)
|
|
21
|
+
return _substitute(skill.template, args)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_args(user_input: str, skill: Skill) -> dict[str, str]:
|
|
25
|
+
"""Parse arguments from user input into a dict."""
|
|
26
|
+
args: dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
# Remove the skill name prefix (e.g., "/commit" -> rest of input)
|
|
29
|
+
parts = user_input.strip().split(None, 1)
|
|
30
|
+
remainder = parts[1] if len(parts) > 1 else ""
|
|
31
|
+
|
|
32
|
+
# Map positional and named args
|
|
33
|
+
skill_args = skill.frontmatter.args
|
|
34
|
+
if not skill_args:
|
|
35
|
+
# No declared args — pass entire remainder as {{input}}
|
|
36
|
+
args["input"] = remainder
|
|
37
|
+
return args
|
|
38
|
+
|
|
39
|
+
# Simple approach: split remainder by spaces, assign positionally
|
|
40
|
+
# Also support --name=value and --name value
|
|
41
|
+
tokens = _tokenize(remainder)
|
|
42
|
+
positional_idx = 0
|
|
43
|
+
|
|
44
|
+
i = 0
|
|
45
|
+
while i < len(tokens):
|
|
46
|
+
token = tokens[i]
|
|
47
|
+
if token.startswith("--"):
|
|
48
|
+
# Named argument
|
|
49
|
+
key = token.lstrip("-")
|
|
50
|
+
if "=" in key:
|
|
51
|
+
k, v = key.split("=", 1)
|
|
52
|
+
args[k] = v
|
|
53
|
+
elif i + 1 < len(tokens):
|
|
54
|
+
args[key] = tokens[i + 1]
|
|
55
|
+
i += 1
|
|
56
|
+
elif positional_idx < len(skill_args):
|
|
57
|
+
args[skill_args[positional_idx].name] = token
|
|
58
|
+
positional_idx += 1
|
|
59
|
+
i += 1
|
|
60
|
+
|
|
61
|
+
# Fill defaults for missing args
|
|
62
|
+
for arg in skill_args:
|
|
63
|
+
if arg.name not in args and arg.default is not None:
|
|
64
|
+
args[arg.name] = str(arg.default)
|
|
65
|
+
|
|
66
|
+
return args
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tokenize(text: str) -> list[str]:
|
|
70
|
+
"""Tokenize input, respecting quoted strings."""
|
|
71
|
+
tokens: list[str] = []
|
|
72
|
+
for match in re.finditer(r'"([^"]*)"' r"|'([^']*)'" r"|(\S+)", text):
|
|
73
|
+
tokens.append(match.group(1) or match.group(2) or match.group(3) or "")
|
|
74
|
+
return tokens
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _substitute(template: str, args: dict[str, str]) -> str:
|
|
78
|
+
"""Replace {{arg}} placeholders in the template."""
|
|
79
|
+
|
|
80
|
+
def replacer(match: re.Match[str]) -> str:
|
|
81
|
+
key = match.group(1).strip()
|
|
82
|
+
return args.get(key, match.group(0))
|
|
83
|
+
|
|
84
|
+
return re.sub(r"\{\{(\w+)\}\}", replacer, template)
|
mita/skills/loader.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Discover and parse skill Markdown files from configured paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SkillFrontmatter(BaseModel):
|
|
14
|
+
"""YAML frontmatter for a skill file."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: str = ""
|
|
18
|
+
args: list[SkillArg] = Field(default_factory=list)
|
|
19
|
+
trigger: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SkillArg(BaseModel):
|
|
23
|
+
"""A parameter accepted by a skill."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
type: str = "string"
|
|
27
|
+
description: str = ""
|
|
28
|
+
required: bool = True
|
|
29
|
+
default: Any | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Rebuild SkillFrontmatter to pick up SkillArg
|
|
33
|
+
SkillFrontmatter.model_rebuild()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Skill(BaseModel):
|
|
37
|
+
"""A parsed skill: frontmatter + Markdown template."""
|
|
38
|
+
|
|
39
|
+
frontmatter: SkillFrontmatter
|
|
40
|
+
template: str
|
|
41
|
+
source_path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def discover_skills(skills_paths: list[str]) -> list[Skill]:
|
|
45
|
+
"""Discover and load all skills from the configured paths."""
|
|
46
|
+
skills: list[Skill] = []
|
|
47
|
+
seen_names: set[str] = set()
|
|
48
|
+
|
|
49
|
+
for raw_path in skills_paths:
|
|
50
|
+
path = Path(raw_path).expanduser()
|
|
51
|
+
if not path.is_dir():
|
|
52
|
+
continue
|
|
53
|
+
for md_file in sorted(path.glob("*.md")):
|
|
54
|
+
skill = parse_skill_file(md_file)
|
|
55
|
+
if skill and skill.frontmatter.name not in seen_names:
|
|
56
|
+
skills.append(skill)
|
|
57
|
+
seen_names.add(skill.frontmatter.name)
|
|
58
|
+
|
|
59
|
+
return skills
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_skill_file(path: Path) -> Skill | None:
|
|
63
|
+
"""Parse a single skill Markdown file."""
|
|
64
|
+
try:
|
|
65
|
+
content = path.read_text(encoding="utf-8")
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
frontmatter, template = _split_frontmatter(content)
|
|
70
|
+
if frontmatter is None:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
data = yaml.safe_load(frontmatter)
|
|
75
|
+
except yaml.YAMLError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if not isinstance(data, dict) or "name" not in data:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
fm = SkillFrontmatter(**data)
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return Skill(frontmatter=fm, template=template.strip(), source_path=path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_skill(skills: list[Skill], name_or_trigger: str) -> Skill | None:
|
|
90
|
+
"""Find a skill by name or trigger pattern."""
|
|
91
|
+
# Strip leading slash for matching
|
|
92
|
+
query = name_or_trigger.lstrip("/").split()[0] if name_or_trigger else ""
|
|
93
|
+
|
|
94
|
+
for skill in skills:
|
|
95
|
+
if skill.frontmatter.name == query:
|
|
96
|
+
return skill
|
|
97
|
+
if skill.frontmatter.trigger:
|
|
98
|
+
if re.search(skill.frontmatter.trigger, name_or_trigger):
|
|
99
|
+
return skill
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _split_frontmatter(content: str) -> tuple[str | None, str]:
|
|
105
|
+
"""Split YAML frontmatter from Markdown body."""
|
|
106
|
+
content = content.strip()
|
|
107
|
+
if not content.startswith("---"):
|
|
108
|
+
return None, content
|
|
109
|
+
|
|
110
|
+
# Find the closing ---
|
|
111
|
+
end = content.find("---", 3)
|
|
112
|
+
if end == -1:
|
|
113
|
+
return None, content
|
|
114
|
+
|
|
115
|
+
frontmatter = content[3:end].strip()
|
|
116
|
+
body = content[end + 3 :].strip()
|
|
117
|
+
return frontmatter, body
|
mita/skills/manager.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""CLI command handlers for skills management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from mita.config.loader import load_config
|
|
12
|
+
from mita.skills.loader import discover_skills
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
SKILL_TEMPLATE = """---
|
|
17
|
+
name: {name}
|
|
18
|
+
description: "{description}"
|
|
19
|
+
args: []
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{name} skill prompt goes here.
|
|
23
|
+
|
|
24
|
+
Use {{{{input}}}} to reference the user's input.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_skills() -> None:
|
|
29
|
+
"""List all available skills."""
|
|
30
|
+
config = load_config()
|
|
31
|
+
skills = discover_skills(config.skills_paths)
|
|
32
|
+
|
|
33
|
+
if not skills:
|
|
34
|
+
console.print("[yellow]No skills found.[/yellow]")
|
|
35
|
+
_show_paths(config.skills_paths)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
table = Table(title="Available Skills")
|
|
39
|
+
table.add_column("Name", style="bold")
|
|
40
|
+
table.add_column("Description")
|
|
41
|
+
table.add_column("Args")
|
|
42
|
+
table.add_column("Source")
|
|
43
|
+
|
|
44
|
+
for skill in skills:
|
|
45
|
+
arg_names = ", ".join(a.name for a in skill.frontmatter.args) or "-"
|
|
46
|
+
table.add_row(
|
|
47
|
+
skill.frontmatter.name,
|
|
48
|
+
skill.frontmatter.description,
|
|
49
|
+
arg_names,
|
|
50
|
+
str(skill.source_path),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def show_skill(name: str) -> None:
|
|
57
|
+
"""Show details and template for a skill."""
|
|
58
|
+
config = load_config()
|
|
59
|
+
skills = discover_skills(config.skills_paths)
|
|
60
|
+
|
|
61
|
+
match = None
|
|
62
|
+
for s in skills:
|
|
63
|
+
if s.frontmatter.name == name:
|
|
64
|
+
match = s
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if not match:
|
|
68
|
+
console.print(f"[red]Skill '{name}' not found.[/red]")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
console.print(f"[bold]Skill:[/bold] {match.frontmatter.name}")
|
|
72
|
+
console.print(f"[bold]Description:[/bold] {match.frontmatter.description}")
|
|
73
|
+
console.print(f"[bold]Source:[/bold] {match.source_path}")
|
|
74
|
+
|
|
75
|
+
if match.frontmatter.args:
|
|
76
|
+
console.print("[bold]Arguments:[/bold]")
|
|
77
|
+
for arg in match.frontmatter.args:
|
|
78
|
+
req = "required" if arg.required else f"optional, default={arg.default}"
|
|
79
|
+
console.print(f" - {arg.name} ({arg.type}): {arg.description} [{req}]")
|
|
80
|
+
|
|
81
|
+
if match.frontmatter.trigger:
|
|
82
|
+
console.print(f"[bold]Trigger:[/bold] {match.frontmatter.trigger}")
|
|
83
|
+
|
|
84
|
+
console.print("\n[bold]Template:[/bold]")
|
|
85
|
+
console.print(Markdown(match.template))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_skill(name: str) -> None:
|
|
89
|
+
"""Create a new skill file from a template."""
|
|
90
|
+
config = load_config()
|
|
91
|
+
|
|
92
|
+
# Use the first writable path (prefer project-local)
|
|
93
|
+
target_dir = None
|
|
94
|
+
for raw_path in reversed(config.skills_paths):
|
|
95
|
+
p = Path(raw_path).expanduser()
|
|
96
|
+
target_dir = p
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if target_dir is None:
|
|
100
|
+
console.print("[red]No skills path configured.[/red]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
target_file = target_dir / f"{name}.md"
|
|
105
|
+
|
|
106
|
+
if target_file.exists():
|
|
107
|
+
console.print(f"[red]Skill '{name}' already exists at {target_file}.[/red]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
content = SKILL_TEMPLATE.format(name=name, description=f"A {name} skill.")
|
|
111
|
+
target_file.write_text(content, encoding="utf-8")
|
|
112
|
+
console.print(f"[green]Created skill '{name}' at {target_file}.[/green]")
|
|
113
|
+
console.print("Edit the file to customize the prompt template.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def show_paths() -> None:
|
|
117
|
+
"""Show skill search paths."""
|
|
118
|
+
config = load_config()
|
|
119
|
+
_show_paths(config.skills_paths)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _show_paths(paths: list[str]) -> None:
|
|
123
|
+
"""Display skill search paths."""
|
|
124
|
+
console.print("[bold]Skill search paths:[/bold]")
|
|
125
|
+
for raw_path in paths:
|
|
126
|
+
p = Path(raw_path).expanduser()
|
|
127
|
+
exists = p.is_dir()
|
|
128
|
+
style = "bold" if exists else "dim"
|
|
129
|
+
console.print(f" {p}", style=style)
|
mita/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tool system: schema, registry, execution, and built-in tools."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Built-in tool definitions and handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mita.tools.builtins.file_edit import TOOL_DEF as FILE_EDIT_DEF
|
|
6
|
+
from mita.tools.builtins.file_edit import execute as file_edit_execute
|
|
7
|
+
from mita.tools.builtins.file_read import TOOL_DEF as FILE_READ_DEF
|
|
8
|
+
from mita.tools.builtins.file_read import execute as file_read_execute
|
|
9
|
+
from mita.tools.builtins.file_write import TOOL_DEF as FILE_WRITE_DEF
|
|
10
|
+
from mita.tools.builtins.file_write import execute as file_write_execute
|
|
11
|
+
from mita.tools.builtins.git import TOOL_DEF as GIT_DEF
|
|
12
|
+
from mita.tools.builtins.git import execute as git_execute
|
|
13
|
+
from mita.tools.builtins.glob_tool import TOOL_DEF as GLOB_DEF
|
|
14
|
+
from mita.tools.builtins.glob_tool import execute as glob_execute
|
|
15
|
+
from mita.tools.builtins.grep_tool import TOOL_DEF as GREP_DEF
|
|
16
|
+
from mita.tools.builtins.grep_tool import execute as grep_execute
|
|
17
|
+
from mita.tools.builtins.shell import TOOL_DEF as SHELL_DEF
|
|
18
|
+
from mita.tools.builtins.shell import execute as shell_execute
|
|
19
|
+
|
|
20
|
+
BUILTIN_TOOLS: dict[str, tuple[object, object]] = {
|
|
21
|
+
FILE_READ_DEF.name: (FILE_READ_DEF, file_read_execute),
|
|
22
|
+
FILE_WRITE_DEF.name: (FILE_WRITE_DEF, file_write_execute),
|
|
23
|
+
FILE_EDIT_DEF.name: (FILE_EDIT_DEF, file_edit_execute),
|
|
24
|
+
GLOB_DEF.name: (GLOB_DEF, glob_execute),
|
|
25
|
+
GREP_DEF.name: (GREP_DEF, grep_execute),
|
|
26
|
+
SHELL_DEF.name: (SHELL_DEF, shell_execute),
|
|
27
|
+
GIT_DEF.name: (GIT_DEF, git_execute),
|
|
28
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Built-in tool: string-replace edit in a file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mita.tools.schema import ToolDefinition, ToolParameter, ToolResult
|
|
9
|
+
|
|
10
|
+
TOOL_DEF = ToolDefinition(
|
|
11
|
+
name="file_edit",
|
|
12
|
+
description=(
|
|
13
|
+
"Edit a file by replacing an exact string match. "
|
|
14
|
+
"The old_string must appear exactly once in the file."
|
|
15
|
+
),
|
|
16
|
+
parameters=[
|
|
17
|
+
ToolParameter(name="path", type="string", description="Path to the file to edit."),
|
|
18
|
+
ToolParameter(
|
|
19
|
+
name="old_string", type="string", description="The exact text to find and replace."
|
|
20
|
+
),
|
|
21
|
+
ToolParameter(name="new_string", type="string", description="The replacement text."),
|
|
22
|
+
],
|
|
23
|
+
destructive=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def execute(args: dict[str, Any]) -> ToolResult:
|
|
28
|
+
"""Perform a string-replace edit on a file."""
|
|
29
|
+
path_str = str(args.get("path", ""))
|
|
30
|
+
old_string = str(args.get("old_string", ""))
|
|
31
|
+
new_string = str(args.get("new_string", ""))
|
|
32
|
+
|
|
33
|
+
if not path_str:
|
|
34
|
+
return ToolResult(tool_call_id="", success=False, error="Missing required parameter: path")
|
|
35
|
+
if not old_string:
|
|
36
|
+
return ToolResult(
|
|
37
|
+
tool_call_id="", success=False, error="Missing required parameter: old_string"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
path = Path(path_str).expanduser().resolve()
|
|
41
|
+
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return ToolResult(tool_call_id="", success=False, error=f"File not found: {path}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
content = path.read_text(encoding="utf-8")
|
|
47
|
+
except OSError as e:
|
|
48
|
+
return ToolResult(tool_call_id="", success=False, error=f"Cannot read file: {e}")
|
|
49
|
+
|
|
50
|
+
count = content.count(old_string)
|
|
51
|
+
if count == 0:
|
|
52
|
+
return ToolResult(
|
|
53
|
+
tool_call_id="",
|
|
54
|
+
success=False,
|
|
55
|
+
error="old_string not found in file. Make sure it matches exactly.",
|
|
56
|
+
)
|
|
57
|
+
if count > 1:
|
|
58
|
+
return ToolResult(
|
|
59
|
+
tool_call_id="",
|
|
60
|
+
success=False,
|
|
61
|
+
error=f"old_string found {count} times. It must be unique. Add more context.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
path.write_text(new_content, encoding="utf-8")
|
|
68
|
+
except OSError as e:
|
|
69
|
+
return ToolResult(tool_call_id="", success=False, error=f"Cannot write file: {e}")
|
|
70
|
+
|
|
71
|
+
return ToolResult(tool_call_id="", success=True, output=f"Edited {path}")
|