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.
Files changed (67) hide show
  1. mita/__init__.py +5 -0
  2. mita/__main__.py +5 -0
  3. mita/agent/__init__.py +1 -0
  4. mita/agent/context.py +43 -0
  5. mita/agent/conversation.py +101 -0
  6. mita/agent/loop.py +594 -0
  7. mita/agent/system_prompt.py +75 -0
  8. mita/cli.py +940 -0
  9. mita/config/__init__.py +6 -0
  10. mita/config/defaults.py +41 -0
  11. mita/config/loader.py +53 -0
  12. mita/config/schema.py +131 -0
  13. mita/hooks/__init__.py +1 -0
  14. mita/hooks/manager.py +94 -0
  15. mita/hooks/runner.py +145 -0
  16. mita/index/__init__.py +1 -0
  17. mita/index/embeddings.py +49 -0
  18. mita/index/manager.py +170 -0
  19. mita/index/parser.py +331 -0
  20. mita/index/retriever.py +53 -0
  21. mita/index/store.py +143 -0
  22. mita/llm/__init__.py +1 -0
  23. mita/llm/client.py +86 -0
  24. mita/llm/instructor.py +80 -0
  25. mita/llm/streaming.py +58 -0
  26. mita/memory/__init__.py +6 -0
  27. mita/memory/discovery.py +61 -0
  28. mita/memory/loader.py +76 -0
  29. mita/memory/manager.py +117 -0
  30. mita/models/__init__.py +13 -0
  31. mita/models/hardware.py +289 -0
  32. mita/models/manager.py +268 -0
  33. mita/models/ollama_client.py +104 -0
  34. mita/models/recommender.py +88 -0
  35. mita/models/registry.py +167 -0
  36. mita/models/server.py +262 -0
  37. mita/plugins/__init__.py +1 -0
  38. mita/plugins/client.py +152 -0
  39. mita/plugins/manager.py +210 -0
  40. mita/py.typed +0 -0
  41. mita/skills/__init__.py +1 -0
  42. mita/skills/executor.py +84 -0
  43. mita/skills/loader.py +117 -0
  44. mita/skills/manager.py +129 -0
  45. mita/tools/__init__.py +1 -0
  46. mita/tools/builtins/__init__.py +28 -0
  47. mita/tools/builtins/file_edit.py +71 -0
  48. mita/tools/builtins/file_read.py +74 -0
  49. mita/tools/builtins/file_write.py +42 -0
  50. mita/tools/builtins/git.py +112 -0
  51. mita/tools/builtins/glob_tool.py +67 -0
  52. mita/tools/builtins/grep_tool.py +93 -0
  53. mita/tools/builtins/shell.py +83 -0
  54. mita/tools/executor.py +80 -0
  55. mita/tools/registry.py +69 -0
  56. mita/tools/safety.py +91 -0
  57. mita/tools/schema.py +87 -0
  58. mita/ui/__init__.py +1 -0
  59. mita/ui/display.py +139 -0
  60. mita/ui/repl.py +88 -0
  61. mita/ui/spinner.py +48 -0
  62. mita/ui/theme.py +23 -0
  63. mita_code-0.1.0.dist-info/METADATA +227 -0
  64. mita_code-0.1.0.dist-info/RECORD +67 -0
  65. mita_code-0.1.0.dist-info/WHEEL +4 -0
  66. mita_code-0.1.0.dist-info/entry_points.txt +3 -0
  67. mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -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
@@ -0,0 +1 @@
1
+ """Skills system: reusable parameterized prompt templates."""
@@ -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}")