Skip to content

Commit edcb4ad

Browse files
feat: transform CLI into interactive AI coding agent
1 parent b7d5ac8 commit edcb4ad

4 files changed

Lines changed: 140 additions & 29 deletions

File tree

mythic_vibe_cli/ai/tools.py

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,64 +4,160 @@
44
import json
55
import shlex
66
import contextlib
7+
import os
8+
import subprocess
9+
from pathlib import Path
710
from typing import Any
811

912
from ..protocols.mcp_tools import build_tool_catalogue
1013

1114

1215
def get_agent_tools() -> list[dict[str, Any]]:
13-
"""Convert MCP tools into OpenAI-compatible tool schemas for the agent."""
16+
"""Convert MCP tools and OS tools into OpenAI-compatible tool schemas for the agent."""
1417
mcp_tools = build_tool_catalogue()
1518
tools = []
19+
20+
# OS Level Tools
21+
tools.extend([
22+
{
23+
"type": "function",
24+
"function": {
25+
"name": "read_file",
26+
"description": "Read the contents of a file.",
27+
"parameters": {
28+
"type": "object",
29+
"properties": {
30+
"path": {"type": "string", "description": "Path to the file to read."}
31+
},
32+
"required": ["path"],
33+
"additionalProperties": False,
34+
}
35+
}
36+
},
37+
{
38+
"type": "function",
39+
"function": {
40+
"name": "write_file",
41+
"description": "Write or overwrite the contents of a file.",
42+
"parameters": {
43+
"type": "object",
44+
"properties": {
45+
"path": {"type": "string", "description": "Path to the file."},
46+
"content": {"type": "string", "description": "Content to write."}
47+
},
48+
"required": ["path", "content"],
49+
"additionalProperties": False,
50+
}
51+
}
52+
},
53+
{
54+
"type": "function",
55+
"function": {
56+
"name": "run_command",
57+
"description": "Run a shell command in the project directory.",
58+
"parameters": {
59+
"type": "object",
60+
"properties": {
61+
"command": {"type": "string", "description": "The shell command to execute."}
62+
},
63+
"required": ["command"],
64+
"additionalProperties": False,
65+
}
66+
}
67+
},
68+
{
69+
"type": "function",
70+
"function": {
71+
"name": "list_dir",
72+
"description": "List the contents of a directory.",
73+
"parameters": {
74+
"type": "object",
75+
"properties": {
76+
"path": {"type": "string", "description": "Path to the directory."}
77+
},
78+
"required": ["path"],
79+
"additionalProperties": False,
80+
}
81+
}
82+
}
83+
])
84+
1685
for mcp_tool in mcp_tools:
17-
# Some providers prefer parameters to be complete JSON schema
1886
parameters = dict(mcp_tool.input_schema)
19-
# Ensure additionalProperties is False if required for strict structured output
20-
# But we leave it as is from mcp_tools.py which already sets it.
2187
tools.append({
2288
"type": "function",
2389
"function": {
24-
"name": mcp_tool.name.replace(".", "_"), # e.g. mythic_vibe.memory_search -> mythic_vibe_memory_search
90+
"name": mcp_tool.name.replace(".", "_"),
2591
"description": mcp_tool.description,
2692
"parameters": parameters,
2793
}
2894
})
2995
return tools
3096

3197

32-
def execute_tool(name: str, arguments: dict[str, Any]) -> str:
98+
def execute_tool(name: str, arguments: dict[str, Any], project_root: Path | None = None) -> str:
3399
"""Execute a tool request and return its stdout/stderr as a string."""
100+
101+
root_path = project_root if project_root is not None else Path.cwd()
102+
103+
if name == "read_file":
104+
path = root_path / arguments.get("path", "")
105+
try:
106+
return path.read_text(encoding="utf-8")
107+
except Exception as exc:
108+
return f"Failed to read file: {exc}"
109+
110+
if name == "write_file":
111+
path = root_path / arguments.get("path", "")
112+
try:
113+
path.parent.mkdir(parents=True, exist_ok=True)
114+
path.write_text(arguments.get("content", ""), encoding="utf-8")
115+
return f"Successfully wrote to {path}"
116+
except Exception as exc:
117+
return f"Failed to write file: {exc}"
118+
119+
if name == "run_command":
120+
command = arguments.get("command", "")
121+
try:
122+
result = subprocess.run(
123+
command, shell=True, cwd=str(root_path),
124+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
125+
)
126+
return result.stdout or f"Command executed with exit code {result.returncode}"
127+
except Exception as exc:
128+
return f"Command failed: {exc}"
129+
130+
if name == "list_dir":
131+
path = root_path / arguments.get("path", ".")
132+
try:
133+
items = os.listdir(path)
134+
return "\\n".join(sorted(items))
135+
except Exception as exc:
136+
return f"Failed to list directory: {exc}"
137+
34138
from ..app import main
35139

36-
# Tools are prefixed with mythic_vibe_
37140
if not name.startswith("mythic_vibe_"):
38141
return f"Error: Unknown tool prefix {name}"
39142

40-
# Extract the actual command name
41143
command_name = name[len("mythic_vibe_"):]
42-
43-
# argv array from the tool arguments
44144
tool_argv = arguments.get("argv", [])
45145
if not isinstance(tool_argv, list):
46146
return f"Error: argv must be a list, got {type(tool_argv)}"
47147

48-
# Ensure they are strings
49148
tool_argv = [str(arg) for arg in tool_argv]
50-
51149
full_argv = [command_name] + tool_argv
52150

53-
# Capture output
54151
stdout = io.StringIO()
55152
stderr = io.StringIO()
56153

57154
try:
58155
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
59156
exit_code = main(full_argv)
60157
except SystemExit as exc:
61-
# Argparse might call sys.exit, we catch it so the shell doesn't die.
62158
exit_code = exc.code if exc.code is not None else 0
63159
except Exception as exc:
64-
return f"Tool execution crashed: {exc}\n{stderr.getvalue()}"
160+
return f"Tool execution crashed: {exc}\\n{stderr.getvalue()}"
65161

66162
output = stdout.getvalue()
67163
err_output = stderr.getvalue()
@@ -70,7 +166,7 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> str:
70166
if output:
71167
result.append(output.strip())
72168
if err_output:
73-
result.append(f"STDERR:\n{err_output.strip()}")
169+
result.append(f"STDERR:\\n{err_output.strip()}")
74170
result.append(f"Exit code: {exit_code}")
75171

76-
return "\n".join(result)
172+
return "\\n".join(result)

mythic_vibe_cli/repl.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,7 @@ def _handle_test_command(
330330
def _looks_like_command(stripped: str, main_commands: set[str]) -> bool:
331331
if stripped.startswith("/"):
332332
return True
333-
try:
334-
argv = shlex.split(stripped)
335-
except ValueError:
336-
return True
337-
return bool(argv and argv[0] in main_commands)
333+
return False
338334

339335

340336
def _known_command_names(_main: Callable[[list[str]], int]) -> set[str]:
@@ -522,7 +518,7 @@ def _answer_with_selected_model(prompt: str, stdout: IO[str], context: ShellCont
522518

523519
print(f" [Executing {tool_name}...]", file=stdout)
524520
try:
525-
tool_output = execute_tool(tool_name, args)
521+
tool_output = execute_tool(tool_name, args, context.project_root)
526522
except Exception as exc:
527523
tool_output = f"Tool execution error: {exc}"
528524

@@ -626,10 +622,27 @@ def run_shell(
626622
last_test_result: dict[str, str] = {}
627623
_print_banner(out_stream, shell_context)
628624

625+
use_prompt_toolkit = False
626+
session = None
627+
if hasattr(in_stream, "isatty") and in_stream.isatty():
628+
try:
629+
from prompt_toolkit import PromptSession
630+
from prompt_toolkit.history import InMemoryHistory
631+
session = PromptSession(history=InMemoryHistory())
632+
use_prompt_toolkit = True
633+
except ImportError:
634+
pass
635+
629636
while True:
630-
print(PROMPT, end="", file=out_stream, flush=True)
631637
try:
632-
line = in_stream.readline()
638+
if use_prompt_toolkit and session is not None:
639+
try:
640+
line = session.prompt(PROMPT) + "\n"
641+
except EOFError:
642+
line = ""
643+
else:
644+
print(PROMPT, end="", file=out_stream, flush=True)
645+
line = in_stream.readline()
633646
except KeyboardInterrupt:
634647
print("(interrupted; type /quit to exit)", file=out_stream)
635648
continue

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ docs = [
7070
"pymdown-extensions>=10.0"
7171
]
7272
ux = [
73-
"rich>=13.0"
73+
"rich>=13.0",
74+
"prompt_toolkit>=3.0"
7475
]
7576
tui = [
7677
"textual>=0.80"
@@ -105,6 +106,7 @@ dev = [
105106
"mkdocs-material>=9.5",
106107
"mypy>=1.10",
107108
"openai>=1.40",
109+
"prompt_toolkit>=3.0",
108110
"pymdown-extensions>=10.0",
109111
"pytest>=8.0",
110112
"pytest-cov>=5.0",

tests/test_repl.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,12 @@ def test_real_command_dispatches_through_main(self) -> None:
126126
self.assertEqual(len(fake.calls), 1)
127127
self.assertEqual(fake.calls[0], ["scan", "--path", "."])
128128

129-
def test_bare_command_dispatches_through_main(self) -> None:
130-
"""A line without a leading slash is also dispatched."""
129+
def test_bare_command_does_not_dispatch_through_main(self) -> None:
130+
"""A line without a leading slash is NOT dispatched to main."""
131131
fake = _FakeMain()
132132
code, _out, _err = self._drive(["status --json\n", "/quit\n"], main=fake)
133133
self.assertEqual(code, SUCCESS)
134-
self.assertEqual(fake.calls, [["status", "--json"]])
134+
self.assertEqual(fake.calls, [])
135135

136136
def test_non_zero_exit_code_is_surfaced_but_loop_continues(self) -> None:
137137
fake = _FakeMain(return_codes=[USER_INPUT_ERROR])

0 commit comments

Comments
 (0)