moma-cli 0.0.1__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ env/
2
+ venv/
3
+ .env
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ .DS_Store
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: moma-cli
3
+ Version: 0.0.1
4
+ Summary: MOMA CLI - A terminal-based AI coding assistant
5
+ Author-email: MOMA CLI <dev@moma-cli.com>
6
+ License: MIT
7
+ Keywords: ai,cli,coding-assistant,llm
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: litellm>=1.0.0
18
+ Requires-Dist: rich>=13.0.0
19
+ Requires-Dist: typer[all]>=0.9.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # moma-cli
27
+
28
+ A terminal‑based AI coding assistant powered by `litellm`. It lets you chat with your codebase, automatically read context, and apply changes manually.
29
+
30
+ ## Overview
31
+
32
+ <img src="asset/image.png" alt="moma-cli screenshot" width="800">
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install moma-cli # or install from the repository
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ```bash
43
+ # Start an interactive chat (default command)
44
+ moma
45
+ ```
46
+
47
+ The CLI will launch a welcome screen and you can start typing messages. Commands are prefixed with `/`:
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `/clear` | Clear conversation history |
52
+ | `/model list` | List available models |
53
+ | `/model change` | Choose a different model |
54
+ | `/security` | Change security mode (smart/strict/permissive) |
55
+ | `/status` | Show current session status |
56
+ | `/config …` | View or edit configuration |
57
+ | `/help` | Show help panel |
58
+ | `/quit` or `Ctrl‑D` | Exit the program |
59
+
60
+ ## Chat command
61
+
62
+ You can also invoke the chat directly with arguments:
63
+
64
+ ```bash
65
+ moma chat "Explain this function" # send an initial message
66
+ moma chat -m gpt-4 "Generate a unit test" # override model for this session
67
+ moma chat -c # clear history before starting
68
+ moma chat -t 0.7 -max-tokens 500 # set temperature and max tokens
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ Configuration is stored in `~/.moma-cli/config.json`. Manage it via the `config` sub‑command:
74
+
75
+ ```bash
76
+ # Show all configuration
77
+ moma config show
78
+
79
+ # Set a value (e.g., default model)
80
+ moma config set default_model ollama/llama3
81
+
82
+ # Set your API key
83
+ moma config set api_key sk-xxxxxxxxxxxx
84
+
85
+ # Reset system prompt to default
86
+ moma config reset
87
+ ```
88
+
89
+ ## Security modes
90
+
91
+ - **smart** – read‑only shell commands are auto‑executed, mutating commands require confirmation.
92
+ - **strict** – all shell commands require confirmation.
93
+ - **permissive** – all commands run without prompts.
94
+
95
+ Change the mode with:
96
+
97
+ ```bash
98
+ moma /security
99
+ ```
100
+
101
+ ## Example workflow
102
+
103
+ 1. **Start chat** – `moma`
104
+ 2. **Ask a question** – e.g., `How can I improve this function?`
105
+ 3. **Review suggestions** – the assistant may propose a diff.
106
+ 4. **Apply changes** – confirm the diff when prompted.
107
+
108
+ Enjoy coding with AI assistance!
@@ -0,0 +1,83 @@
1
+ # moma-cli
2
+
3
+ A terminal‑based AI coding assistant powered by `litellm`. It lets you chat with your codebase, automatically read context, and apply changes manually.
4
+
5
+ ## Overview
6
+
7
+ <img src="asset/image.png" alt="moma-cli screenshot" width="800">
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install moma-cli # or install from the repository
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ # Start an interactive chat (default command)
19
+ moma
20
+ ```
21
+
22
+ The CLI will launch a welcome screen and you can start typing messages. Commands are prefixed with `/`:
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | `/clear` | Clear conversation history |
27
+ | `/model list` | List available models |
28
+ | `/model change` | Choose a different model |
29
+ | `/security` | Change security mode (smart/strict/permissive) |
30
+ | `/status` | Show current session status |
31
+ | `/config …` | View or edit configuration |
32
+ | `/help` | Show help panel |
33
+ | `/quit` or `Ctrl‑D` | Exit the program |
34
+
35
+ ## Chat command
36
+
37
+ You can also invoke the chat directly with arguments:
38
+
39
+ ```bash
40
+ moma chat "Explain this function" # send an initial message
41
+ moma chat -m gpt-4 "Generate a unit test" # override model for this session
42
+ moma chat -c # clear history before starting
43
+ moma chat -t 0.7 -max-tokens 500 # set temperature and max tokens
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ Configuration is stored in `~/.moma-cli/config.json`. Manage it via the `config` sub‑command:
49
+
50
+ ```bash
51
+ # Show all configuration
52
+ moma config show
53
+
54
+ # Set a value (e.g., default model)
55
+ moma config set default_model ollama/llama3
56
+
57
+ # Set your API key
58
+ moma config set api_key sk-xxxxxxxxxxxx
59
+
60
+ # Reset system prompt to default
61
+ moma config reset
62
+ ```
63
+
64
+ ## Security modes
65
+
66
+ - **smart** – read‑only shell commands are auto‑executed, mutating commands require confirmation.
67
+ - **strict** – all shell commands require confirmation.
68
+ - **permissive** – all commands run without prompts.
69
+
70
+ Change the mode with:
71
+
72
+ ```bash
73
+ moma /security
74
+ ```
75
+
76
+ ## Example workflow
77
+
78
+ 1. **Start chat** – `moma`
79
+ 2. **Ask a question** – e.g., `How can I improve this function?`
80
+ 3. **Review suggestions** – the assistant may propose a diff.
81
+ 4. **Apply changes** – confirm the diff when prompted.
82
+
83
+ Enjoy coding with AI assistance!
Binary file
@@ -0,0 +1,4 @@
1
+ """MOMA CLI - A terminal-based AI coding assistant."""
2
+
3
+ __version__ = "0.0.1"
4
+ __author__ = "MOMA CLI, Moma Team"
@@ -0,0 +1,360 @@
1
+ """Agent module for MOMA CLI.
2
+
3
+ Manages conversation history, system prompts, and handles the litellm
4
+ completion loop including tool calling/function execution.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Generator
10
+
11
+ import litellm
12
+ from rich.console import Console
13
+ from rich.prompt import Prompt
14
+
15
+ from moma.config import ConfigManager, get_config
16
+ from moma.security import SecurityPolicy, confirm_command
17
+ from moma.tools import TOOLS, execute_tool_call, TOOL_FUNCTIONS
18
+ from moma.diff_applier import propose_file_write, CodeProposal, apply_proposal
19
+
20
+
21
+ class ConversationHistory:
22
+ """Manages the conversation history with the LLM."""
23
+
24
+ def __init__(self):
25
+ """Initialize the conversation history."""
26
+ self.messages: List[Dict[str, Any]] = []
27
+
28
+ def add_system(self, prompt: str) -> None:
29
+ """Add a system message."""
30
+ self.messages.append({"role": "system", "content": prompt})
31
+
32
+ def add_user(self, content: str) -> None:
33
+ """Add a user message."""
34
+ self.messages.append({"role": "user", "content": content})
35
+
36
+ def add_assistant(self, content: Optional[str], tool_calls: Optional[List[Dict]] = None) -> None:
37
+ """Add an assistant message."""
38
+ msg: Dict[str, Any] = {"role": "assistant"}
39
+ msg["content"] = content if content is not None else ""
40
+ if tool_calls:
41
+ msg["tool_calls"] = tool_calls
42
+ self.messages.append(msg)
43
+
44
+ def add_tool_result(self, tool_call_id: str, name: str, content: str, is_error: bool = False) -> None:
45
+ """Add a tool result message."""
46
+ self.messages.append({
47
+ "role": "tool",
48
+ "tool_call_id": tool_call_id,
49
+ "name": name,
50
+ "content": content,
51
+ })
52
+
53
+ def get_messages(self) -> List[Dict[str, Any]]:
54
+ """Get all messages."""
55
+ return self.messages.copy()
56
+
57
+ def clear(self) -> None:
58
+ """Clear all messages (keeps system message if any)."""
59
+ system_msgs = [m for m in self.messages if m["role"] == "system"]
60
+ self.messages.clear()
61
+ for msg in system_msgs:
62
+ self.messages.append(msg)
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.messages)
66
+
67
+
68
+ class Agent:
69
+ """The main agent that handles LLM interaction."""
70
+
71
+ MAX_TOOL_DEPTH = 10
72
+
73
+ def __init__(self, config: Optional[ConfigManager] = None, console: Optional[Console] = None):
74
+ """Initialize the agent."""
75
+ self.config = config or get_config()
76
+ self.console = console or Console()
77
+ self.history = ConversationHistory()
78
+ self.security = SecurityPolicy(mode=self.config.get_security_mode())
79
+ self._task_completed = False
80
+ self._tools_supported = True # flipped off if backend rejects tool_choice
81
+ self._setup()
82
+
83
+ def _setup(self) -> None:
84
+ """Set up the agent with system prompt and defaults."""
85
+ system_prompt = self.config.get_system_prompt()
86
+ self.history.add_system(system_prompt)
87
+
88
+ def _get_model(self) -> str:
89
+ """Get the LLM model to use, auto-prefixing with 'openai/' for the proxy."""
90
+ model = self.config.get("default_model", "openai/gpt-4o")
91
+ if "/" not in model:
92
+ model = f"openai/{model}"
93
+ return model
94
+
95
+ def _get_temperature(self) -> float:
96
+ """Get the temperature setting."""
97
+ return self.config.get("temperature", 0.7)
98
+
99
+ def _get_max_tokens(self) -> int:
100
+ """Get the max tokens setting."""
101
+ return self.config.get("max_tokens", 4096)
102
+
103
+ def _resolve_api_key(self) -> str:
104
+ """Return the configured API key, falling back to env vars."""
105
+ import os
106
+ key = self.config.get("api_key")
107
+ if key:
108
+ return key
109
+ return os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY") or "sk-no-key"
110
+
111
+ def _build_environment_details(self) -> str:
112
+ """Build environment context with the current directory file tree."""
113
+ cwd = Path.cwd()
114
+ IGNORE = {"__pycache__", ".git", "node_modules", ".venv", "venv", "env",
115
+ "dist", "build", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
116
+ try:
117
+ entries = []
118
+ for item in sorted(cwd.rglob("*")):
119
+ parts = item.relative_to(cwd).parts
120
+ if any(p in IGNORE or p.startswith(".") for p in parts):
121
+ continue
122
+ rel = str(item.relative_to(cwd))
123
+ entries.append(f"{rel}{'/' if item.is_dir() else ''}")
124
+ file_list = "\n".join(entries[:500])
125
+ return (
126
+ f"<environment_details>\n"
127
+ f"Current Working Directory: {cwd}\n\n"
128
+ f"File Tree:\n{file_list}\n"
129
+ f"</environment_details>"
130
+ )
131
+ except Exception:
132
+ return f"<environment_details>\nCurrent Working Directory: {cwd}\n</environment_details>"
133
+
134
+ def chat(self, user_message: str) -> Generator[str, None, None]:
135
+ """Process a user message and yield the response.
136
+
137
+ Yields:
138
+ Text chunks of the response.
139
+ """
140
+ # Inject file tree context on the first user message
141
+ if len(self.history.messages) <= 1:
142
+ env = self._build_environment_details()
143
+ full_message = f"{user_message}\n\n{env}"
144
+ else:
145
+ full_message = user_message
146
+ self.history.add_user(full_message)
147
+ self._task_completed = False
148
+ self.console.print("\n[bold blue]🤔 MOMA Assistant is thinking...[/bold blue]")
149
+ yield from self._run_round()
150
+
151
+ def _run_round(self, depth: int = 0) -> Generator[str, None, None]:
152
+ """Make one LLM API call and handle the full response including tool calls.
153
+
154
+ Recurses up to MAX_TOOL_DEPTH times to handle chained tool use.
155
+ """
156
+ if depth >= self.MAX_TOOL_DEPTH:
157
+ self.console.print("\n[yellow]⚠️ Maximum tool call depth reached.[/yellow]")
158
+ return
159
+
160
+ model = self._get_model()
161
+ temperature = self._get_temperature()
162
+ max_tokens = self._get_max_tokens()
163
+ api_key = self._resolve_api_key()
164
+ api_base = self.config.get("api_base")
165
+ messages = self.history.get_messages()
166
+
167
+ def _call_llm(with_tools: bool):
168
+ return litellm.completion(
169
+ model=model,
170
+ messages=messages,
171
+ temperature=temperature,
172
+ max_tokens=max_tokens,
173
+ api_key=api_key,
174
+ api_base=api_base,
175
+ stream=True,
176
+ tools=TOOLS if with_tools else None,
177
+ )
178
+
179
+ try:
180
+ try:
181
+ response = _call_llm(self._tools_supported)
182
+ except litellm.BadRequestError as e:
183
+ if self._tools_supported and "tool choice" in str(e).lower():
184
+ self._tools_supported = False
185
+ self.console.print("[yellow]⚠️ Tool calling disabled (model doesn't support it)[/yellow]")
186
+ response = _call_llm(False)
187
+ else:
188
+ raise
189
+
190
+ response_content = ""
191
+ response_tool_calls: List[Dict] = []
192
+ has_tool_calls = False
193
+
194
+ for chunk in response:
195
+ if chunk.choices:
196
+ choice = chunk.choices[0]
197
+ if choice.delta.content:
198
+ response_content += choice.delta.content
199
+ yield choice.delta.content
200
+ if choice.delta.tool_calls:
201
+ has_tool_calls = True
202
+ for tc in choice.delta.tool_calls:
203
+ if tc.index is not None:
204
+ while len(response_tool_calls) <= tc.index:
205
+ response_tool_calls.append({"id": "", "function": {"name": "", "arguments": ""}})
206
+ # Capture id whenever it appears (not just on first/name chunk)
207
+ if tc.id:
208
+ response_tool_calls[tc.index]["id"] = tc.id
209
+ if tc.function:
210
+ if tc.function.name:
211
+ response_tool_calls[tc.index]["function"]["name"] = tc.function.name
212
+ if tc.function.arguments:
213
+ response_tool_calls[tc.index]["function"]["arguments"] += tc.function.arguments
214
+
215
+ if has_tool_calls:
216
+ # API protocol: assistant message with tool_calls must precede tool result messages
217
+ formatted_tool_calls = [
218
+ {"id": tc["id"], "type": "function", "function": tc["function"]}
219
+ for tc in response_tool_calls
220
+ ]
221
+ self.history.add_assistant(response_content, tool_calls=formatted_tool_calls)
222
+
223
+ self._process_tool_calls(response_tool_calls)
224
+
225
+ if not self._task_completed:
226
+ self.console.print("\n[bold blue]🔄 Processing tool results...[/bold blue]")
227
+ yield from self._run_round(depth + 1)
228
+ else:
229
+ self.history.add_assistant(response_content)
230
+
231
+ except litellm.exceptions.AuthenticationError as e:
232
+ self.console.print(f"\n[bold red]❌ Authentication Error:[/bold red] {e}")
233
+ except litellm.exceptions.ContextWindowExceededError:
234
+ self.console.print("\n[bold red]❌ Context window exceeded.[/bold red] Try /clear to reset history.")
235
+ except Exception as e:
236
+ self.console.print(f"\n[bold red]❌ Error:[/bold red] {e}")
237
+
238
+ def _process_tool_calls(self, response_tool_calls: List[Dict]) -> None:
239
+ """Execute tool calls and record results in history.
240
+
241
+ Sets self._task_completed = True and returns early if attempt_completion is called.
242
+ """
243
+ for tc in response_tool_calls:
244
+ tool_call = {
245
+ "id": tc.get("id", ""),
246
+ "name": tc["function"]["name"],
247
+ "arguments": tc["function"]["arguments"],
248
+ }
249
+
250
+ try:
251
+ args = json.loads(tc["function"]["arguments"]) if tc["function"]["arguments"] else {}
252
+ except json.JSONDecodeError:
253
+ args = {}
254
+
255
+ tool_name = tool_call["name"]
256
+ tool_result = ""
257
+
258
+ if tool_name == "execute_command":
259
+ command = args.get("command", "")
260
+ requires_approval = args.get("requires_approval", True)
261
+
262
+ self.console.print(f"\n[bold]🔧 Executing command:[/bold] [cyan]{command}[/cyan]")
263
+
264
+ validation = self.security.validate_command(command)
265
+
266
+ if not validation["allowed"]:
267
+ # Hard block — never execute regardless of user input
268
+ self.console.print(f"\n[bold red]⛔ BLOCKED:[/bold red] Security policy forbids: [cyan]{command}[/cyan]")
269
+ tool_result = json.dumps({
270
+ "success": False,
271
+ "error": "Command is blocked by security policy",
272
+ "command": command,
273
+ })
274
+ elif requires_approval and validation["requires_confirmation"]:
275
+ confirmed = confirm_command(command, validation.get("category", "mutating"))
276
+ if not confirmed:
277
+ tool_result = json.dumps({
278
+ "success": False,
279
+ "error": "Command was rejected by user",
280
+ "command": command,
281
+ })
282
+ else:
283
+ result = TOOL_FUNCTIONS["execute_command"](command=command, requires_approval=False)
284
+ tool_result = json.dumps(result)
285
+ else:
286
+ result = TOOL_FUNCTIONS["execute_command"](command=command, requires_approval=False)
287
+ tool_result = json.dumps(result)
288
+
289
+ elif tool_name == "propose_file_write":
290
+ file_path = args.get("path", "")
291
+ content = args.get("content", "")
292
+
293
+ if file_path and content:
294
+ confirmed = propose_file_write(file_path, content, _console=self.console)
295
+ if confirmed:
296
+ # Actually write the file after user approval
297
+ write_result = apply_proposal(CodeProposal(file_path, content))
298
+ tool_result = json.dumps(write_result)
299
+ else:
300
+ tool_result = json.dumps({
301
+ "success": False,
302
+ "error": "Changes were rejected by user",
303
+ })
304
+ else:
305
+ tool_result = json.dumps({
306
+ "success": False,
307
+ "error": "Missing path or content",
308
+ })
309
+
310
+ elif tool_name == "attempt_completion":
311
+ result_text = args.get("result", "")
312
+ self.console.print("\n[bold green]✅ Task completed:[/bold green]")
313
+ if result_text:
314
+ from rich.markdown import Markdown as _Markdown
315
+ self.console.print(_Markdown(result_text))
316
+ self._task_completed = True
317
+ return # Don't add to history; stop processing further tool calls
318
+
319
+ elif tool_name == "ask_followup_question":
320
+ question = args.get("question", "")
321
+ options = args.get("options")
322
+
323
+ if options:
324
+ self.console.print(f"\n[bold]{question}[/bold]")
325
+ for i, opt in enumerate(options, 1):
326
+ self.console.print(f" [{i}] {opt}")
327
+ answer = Prompt.ask("Select option", console=self.console, choices=[str(i) for i in range(1, len(options) + 1)])
328
+ answer = options[int(answer) - 1]
329
+ else:
330
+ self.console.print(f"\n[bold]{question}[/bold]")
331
+ answer = Prompt.ask("Your answer", console=self.console)
332
+
333
+ tool_result = json.dumps({"success": True, "answer": answer})
334
+
335
+ else:
336
+ result = execute_tool_call(tool_call)
337
+ tool_result = result["content"]
338
+
339
+ # Derive is_error from the "success" key (not the absent "is_error" key)
340
+ try:
341
+ is_error = not json.loads(tool_result).get("success", True) if tool_result.startswith("{") else False
342
+ except (json.JSONDecodeError, AttributeError):
343
+ is_error = False
344
+
345
+ self.history.add_tool_result(tool_call["id"], tool_name, tool_result, is_error=is_error)
346
+
347
+ def clear_history(self) -> None:
348
+ """Clear the conversation history."""
349
+ self.history.clear()
350
+ self.console.print("[green]✅ Conversation history cleared.[/green]")
351
+
352
+ def get_status(self) -> Dict[str, Any]:
353
+ """Get the current agent status."""
354
+ return {
355
+ "model": self._get_model(),
356
+ "security_mode": self.config.get_security_mode(),
357
+ "history_length": len(self.history),
358
+ "temperature": self._get_temperature(),
359
+ "max_tokens": self._get_max_tokens(),
360
+ }