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
mita/cli.py ADDED
@@ -0,0 +1,940 @@
1
+ """Typer CLI app — top-level command routing for Mita Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import sys
7
+ from io import StringIO
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.syntax import Syntax
13
+
14
+ import mita
15
+ from mita.config.loader import load_config
16
+ from mita.memory.manager import add_memory, edit_memory, show_memory, show_memory_paths
17
+ from mita.models.manager import (
18
+ list_models,
19
+ pull_model,
20
+ remove_model,
21
+ set_default_model,
22
+ show_hardware,
23
+ show_model_info,
24
+ show_recommendations,
25
+ )
26
+
27
+ console = Console()
28
+
29
+ app = typer.Typer(
30
+ name="mita",
31
+ help="Local-first agentic coding assistant powered by Ollama.",
32
+ no_args_is_help=True,
33
+ add_completion=True,
34
+ )
35
+
36
+
37
+ class OutputFormat(enum.StrEnum):
38
+ """Output format for non-interactive ask command."""
39
+
40
+ RICH = "rich"
41
+ TEXT = "text"
42
+ JSON = "json"
43
+
44
+
45
+ # ── Version ───────────────────────────────────────────────────────
46
+
47
+
48
+ def _version_callback(value: bool) -> None:
49
+ if value:
50
+ console.print(f"mita {mita.__version__}")
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback()
55
+ def main(
56
+ version: Annotated[
57
+ bool | None,
58
+ typer.Option(
59
+ "--version",
60
+ "-v",
61
+ help="Show version and exit.",
62
+ callback=_version_callback,
63
+ is_eager=True,
64
+ ),
65
+ ] = None,
66
+ ) -> None:
67
+ """Mita Code — local-first agentic coding assistant."""
68
+
69
+
70
+ # ── Config commands ───────────────────────────────────────────────
71
+
72
+ config_app = typer.Typer(help="Configuration management.")
73
+ app.add_typer(config_app, name="config")
74
+
75
+
76
+ @config_app.command("show")
77
+ def config_show() -> None:
78
+ """Show the merged configuration."""
79
+ cfg = load_config()
80
+ toml_str = _config_to_toml(cfg)
81
+ console.print(Syntax(toml_str, "toml", theme="monokai"))
82
+
83
+
84
+ @config_app.command("path")
85
+ def config_path() -> None:
86
+ """Show config file paths."""
87
+ from mita.config.defaults import get_global_config_path, get_project_config_path
88
+
89
+ global_path = get_global_config_path()
90
+ project_path = get_project_config_path()
91
+
92
+ console.print(f" Global: {global_path}", style="bold" if global_path.is_file() else "dim")
93
+ if project_path:
94
+ console.print(f" Project: {project_path}", style="bold")
95
+ else:
96
+ console.print(" Project: [dim]not found[/dim]")
97
+
98
+
99
+ # ── Memory commands ───────────────────────────────────────────────
100
+
101
+ memory_app = typer.Typer(help="MITA.md memory management.")
102
+ app.add_typer(memory_app, name="memory")
103
+
104
+
105
+ @memory_app.command("show")
106
+ def memory_show() -> None:
107
+ """Show all discovered memory content."""
108
+ show_memory()
109
+
110
+
111
+ @memory_app.command("path")
112
+ def memory_path() -> None:
113
+ """Show discovered memory file paths."""
114
+ show_memory_paths()
115
+
116
+
117
+ @memory_app.command("edit")
118
+ def memory_edit(
119
+ is_global: Annotated[bool, typer.Option("--global", "-g", help="Edit global memory.")] = False,
120
+ project: Annotated[bool, typer.Option("--project", "-p", help="Edit project memory.")] = False,
121
+ ) -> None:
122
+ """Open a MITA.md file in $EDITOR."""
123
+ if is_global:
124
+ edit_memory("global")
125
+ elif project:
126
+ edit_memory("project")
127
+ else:
128
+ edit_memory()
129
+
130
+
131
+ @memory_app.command("add")
132
+ def memory_add(
133
+ text: Annotated[str, typer.Argument(help="Text to add to memory.")],
134
+ is_global: Annotated[
135
+ bool, typer.Option("--global", "-g", help="Add to global memory.")
136
+ ] = False,
137
+ project: Annotated[
138
+ bool, typer.Option("--project", "-p", help="Add to project memory.")
139
+ ] = False,
140
+ ) -> None:
141
+ """Append a line to a MITA.md file."""
142
+ if is_global:
143
+ add_memory(text, "global")
144
+ elif project:
145
+ add_memory(text, "project")
146
+ else:
147
+ add_memory(text, "project")
148
+
149
+
150
+ # ── Models commands ───────────────────────────────────────────────
151
+
152
+ models_app = typer.Typer(help="Model management.")
153
+ app.add_typer(models_app, name="models")
154
+
155
+
156
+ @models_app.command("list")
157
+ def models_list() -> None:
158
+ """List installed Ollama models."""
159
+ list_models()
160
+
161
+
162
+ @models_app.command("pull")
163
+ def models_pull(
164
+ name: Annotated[str, typer.Argument(help="Model name to pull (e.g. qwen2.5-coder:7b).")],
165
+ ) -> None:
166
+ """Pull a model from the Ollama registry."""
167
+ pull_model(name)
168
+
169
+
170
+ @models_app.command("remove")
171
+ def models_remove(
172
+ name: Annotated[str, typer.Argument(help="Model name to remove.")],
173
+ ) -> None:
174
+ """Remove an installed model."""
175
+ remove_model(name)
176
+
177
+
178
+ @models_app.command("recommend")
179
+ def models_recommend() -> None:
180
+ """Recommend models for your hardware."""
181
+ show_recommendations()
182
+
183
+
184
+ @models_app.command("info")
185
+ def models_info(
186
+ name: Annotated[str, typer.Argument(help="Model name to show info for.")],
187
+ ) -> None:
188
+ """Show details about a model."""
189
+ show_model_info(name)
190
+
191
+
192
+ @models_app.command("default")
193
+ def models_default(
194
+ name: Annotated[str, typer.Argument(help="Model name to set as default.")],
195
+ ) -> None:
196
+ """Set the default model."""
197
+ set_default_model(name)
198
+
199
+
200
+ @models_app.command("hardware")
201
+ def models_hardware() -> None:
202
+ """Show detected hardware information."""
203
+ show_hardware()
204
+
205
+
206
+ # ── Index commands ────────────────────────────────────────────────
207
+
208
+ index_app = typer.Typer(help="Codebase index management.")
209
+ app.add_typer(index_app, name="index")
210
+
211
+
212
+ @index_app.command("build")
213
+ def index_build(
214
+ force: Annotated[bool, typer.Option("--force", "-f", help="Force rebuild the index.")] = False,
215
+ ) -> None:
216
+ """Build the codebase index."""
217
+ import asyncio
218
+
219
+ from mita.index.manager import build_index
220
+
221
+ async def _ensure_model(config: object) -> bool:
222
+ """Pull the embedding model if missing. Lives in cli.py to respect dependency rules."""
223
+ from mita.config.schema import MitaConfig
224
+ from mita.index.embeddings import EmbeddingClient
225
+ from mita.models.ollama_client import OllamaClient
226
+
227
+ assert isinstance(config, MitaConfig)
228
+ embedder = EmbeddingClient(config)
229
+ console.print(f"[yellow]Embedding model '{embedder.model}' is not installed.[/yellow]")
230
+ confirm = console.input(f"Pull '{embedder.model}' now? [Y/n] ").strip().lower()
231
+ if confirm not in ("", "y", "yes"):
232
+ console.print("[red]Cannot build index without embedding model.[/red]")
233
+ return False
234
+ client = OllamaClient(host=config.ollama.host)
235
+ console.print(f"Pulling {embedder.model}...")
236
+ for _progress in client.pull(embedder.model):
237
+ pass
238
+ console.print("[green]Model pulled successfully.[/green]")
239
+ return True
240
+
241
+ asyncio.run(build_index(force=force, pull_model_fn=_ensure_model))
242
+
243
+
244
+ @index_app.command("status")
245
+ def index_status() -> None:
246
+ """Show index statistics."""
247
+ import asyncio
248
+
249
+ from mita.index.manager import show_index_status
250
+
251
+ asyncio.run(show_index_status())
252
+
253
+
254
+ @index_app.command("search")
255
+ def index_search(
256
+ query: Annotated[str, typer.Argument(help="Search query.")],
257
+ top_k: Annotated[int, typer.Option("--top-k", "-k", help="Number of results.")] = 10,
258
+ ) -> None:
259
+ """Search the codebase index."""
260
+ import asyncio
261
+
262
+ from mita.index.manager import search_index
263
+
264
+ asyncio.run(search_index(query, top_k=top_k))
265
+
266
+
267
+ @index_app.command("clear")
268
+ def index_clear() -> None:
269
+ """Delete the codebase index."""
270
+ import asyncio
271
+
272
+ from mita.index.manager import clear_index
273
+
274
+ asyncio.run(clear_index())
275
+
276
+
277
+ # ── Skills commands ───────────────────────────────────────────────
278
+
279
+ skills_app = typer.Typer(help="Skills management.")
280
+ app.add_typer(skills_app, name="skills")
281
+
282
+
283
+ @skills_app.command("list")
284
+ def skills_list() -> None:
285
+ """List all available skills."""
286
+ from mita.skills.manager import list_skills
287
+
288
+ list_skills()
289
+
290
+
291
+ @skills_app.command("show")
292
+ def skills_show(
293
+ name: Annotated[str, typer.Argument(help="Skill name to show.")],
294
+ ) -> None:
295
+ """Show details about a skill."""
296
+ from mita.skills.manager import show_skill
297
+
298
+ show_skill(name)
299
+
300
+
301
+ @skills_app.command("create")
302
+ def skills_create(
303
+ name: Annotated[str, typer.Argument(help="Name for the new skill.")],
304
+ ) -> None:
305
+ """Create a new skill from a template."""
306
+ from mita.skills.manager import create_skill
307
+
308
+ create_skill(name)
309
+
310
+
311
+ @skills_app.command("path")
312
+ def skills_path() -> None:
313
+ """Show skill search paths."""
314
+ from mita.skills.manager import show_paths
315
+
316
+ show_paths()
317
+
318
+
319
+ # ── Hook commands ─────────────────────────────────────────────────
320
+
321
+ hooks_app = typer.Typer(help="Lifecycle hooks management.")
322
+ app.add_typer(hooks_app, name="hooks")
323
+
324
+
325
+ @hooks_app.command("list")
326
+ def hooks_list() -> None:
327
+ """List configured hooks."""
328
+ from mita.hooks.manager import list_hooks
329
+
330
+ list_hooks()
331
+
332
+
333
+ @hooks_app.command("add")
334
+ def hooks_add(
335
+ event: Annotated[str, typer.Argument(help="Hook event (e.g. on_file_write).")],
336
+ command: Annotated[str, typer.Argument(help="Shell command to run.")],
337
+ match: Annotated[str | None, typer.Option("--match", "-m", help="Glob pattern filter.")] = None,
338
+ ) -> None:
339
+ """Add a hook to project config."""
340
+ from mita.hooks.manager import add_hook
341
+
342
+ add_hook(event, command, match)
343
+
344
+
345
+ @hooks_app.command("remove")
346
+ def hooks_remove(
347
+ event: Annotated[str, typer.Argument(help="Hook event to remove.")],
348
+ ) -> None:
349
+ """Remove hooks for an event from project config."""
350
+ from mita.hooks.manager import remove_hooks
351
+
352
+ remove_hooks(event)
353
+
354
+
355
+ # ── Plugin commands ───────────────────────────────────────────────
356
+
357
+ plugins_app = typer.Typer(help="MCP plugin management.")
358
+ app.add_typer(plugins_app, name="plugins")
359
+
360
+
361
+ @plugins_app.command("list")
362
+ def plugins_list() -> None:
363
+ """List configured plugins and their tools."""
364
+ import asyncio
365
+
366
+ from mita.plugins.manager import PluginManager
367
+
368
+ cfg = load_config()
369
+ if not cfg.plugins:
370
+ console.print("[dim]No plugins configured.[/dim]")
371
+ return
372
+
373
+ async def _list() -> None:
374
+ mgr = PluginManager(cfg.plugins)
375
+ started = await mgr.start_all(console=console)
376
+ try:
377
+ for plugin in cfg.plugins:
378
+ connected = plugin.name in started
379
+ status = "[green]connected[/green]" if connected else "[red]not connected[/red]"
380
+ transport = plugin.transport
381
+ target = plugin.command or plugin.url or ""
382
+ console.print(f" {plugin.name} ({transport}) — {status}")
383
+ console.print(f" {target}")
384
+
385
+ if connected:
386
+ tools_map = await mgr.list_tools(plugin.name)
387
+ tools = tools_map.get(plugin.name, [])
388
+ if tools:
389
+ for t in tools:
390
+ console.print(f" - {t['name']}: {t['description']}")
391
+ else:
392
+ console.print(" [dim]No tools[/dim]")
393
+ finally:
394
+ await mgr.stop_all()
395
+
396
+ asyncio.run(_list())
397
+
398
+
399
+ @plugins_app.command("add")
400
+ def plugins_add(
401
+ name: Annotated[str, typer.Argument(help="Plugin name.")],
402
+ command: Annotated[
403
+ str | None, typer.Option("--command", "-c", help="Command for stdio.")
404
+ ] = None,
405
+ url: Annotated[str | None, typer.Option("--url", "-u", help="URL for SSE.")] = None,
406
+ ) -> None:
407
+ """Add an MCP plugin to project config."""
408
+ if not command and not url:
409
+ console.print("[red]Provide --command or --url.[/red]")
410
+ raise typer.Exit(1)
411
+
412
+ from pathlib import Path
413
+
414
+ from mita.config.defaults import (
415
+ PROJECT_CONFIG_DIR,
416
+ PROJECT_CONFIG_FILE,
417
+ _find_project_root,
418
+ get_project_config_path,
419
+ )
420
+
421
+ project_path = get_project_config_path()
422
+ if not project_path:
423
+ # Create .mita/settings.toml if we're in a project root (has .git)
424
+ root = _find_project_root(Path.cwd())
425
+ if root is None:
426
+ console.print("[red]Not in a project directory (no .git or .mita/).[/red]")
427
+ raise typer.Exit(1)
428
+ config_dir = root / PROJECT_CONFIG_DIR
429
+ config_dir.mkdir(exist_ok=True)
430
+ project_path = config_dir / PROJECT_CONFIG_FILE
431
+ project_path.touch()
432
+
433
+ transport = "stdio" if command else "sse"
434
+ # Build TOML block
435
+ lines = [f'\n[[plugins]]\nname = "{name}"\ntransport = "{transport}"']
436
+ if command:
437
+ # Split command into executable + args
438
+ parts = command.split()
439
+ lines.append(f'command = "{parts[0]}"')
440
+ if len(parts) > 1:
441
+ args_toml = ", ".join(f'"{a}"' for a in parts[1:])
442
+ lines.append(f"args = [{args_toml}]")
443
+ if url:
444
+ lines.append(f'url = "{url}"')
445
+
446
+ block = "\n".join(lines) + "\n"
447
+
448
+ with open(project_path, "a") as f:
449
+ f.write(block)
450
+
451
+ console.print(f"[green]Plugin '{name}' added to {project_path}[/green]")
452
+
453
+
454
+ @plugins_app.command("remove")
455
+ def plugins_remove(
456
+ name: Annotated[str, typer.Argument(help="Plugin name to remove.")],
457
+ ) -> None:
458
+ """Remove an MCP plugin from project config."""
459
+ from mita.config.defaults import get_project_config_path
460
+
461
+ project_path = get_project_config_path()
462
+ if not project_path or not project_path.is_file():
463
+ console.print("[red]No project config found.[/red]")
464
+ raise typer.Exit(1)
465
+
466
+ import tomllib
467
+
468
+ with open(project_path, "rb") as f:
469
+ data = tomllib.load(f)
470
+
471
+ plugins = data.get("plugins", [])
472
+ new_plugins = [p for p in plugins if p.get("name") != name]
473
+ if len(new_plugins) == len(plugins):
474
+ console.print(f"[yellow]Plugin '{name}' not found in project config.[/yellow]")
475
+ return
476
+
477
+ data["plugins"] = new_plugins
478
+ _write_toml(project_path, data)
479
+ console.print(f"[green]Plugin '{name}' removed.[/green]")
480
+
481
+
482
+ @plugins_app.command("test")
483
+ def plugins_test(
484
+ name: Annotated[str, typer.Argument(help="Plugin name to test.")],
485
+ ) -> None:
486
+ """Test connectivity to an MCP plugin."""
487
+ import asyncio
488
+
489
+ from mita.plugins.manager import PluginManager
490
+
491
+ cfg = load_config()
492
+ plugin = next((p for p in cfg.plugins if p.name == name), None)
493
+ if not plugin:
494
+ console.print(f"[red]Plugin '{name}' not found in configuration.[/red]")
495
+ raise typer.Exit(1)
496
+
497
+ async def _test() -> None:
498
+ mgr = PluginManager([plugin])
499
+ started = await mgr.start_all(console=console)
500
+ try:
501
+ if name not in started:
502
+ console.print(f"[red]Failed to connect to '{name}'.[/red]")
503
+ raise typer.Exit(1)
504
+
505
+ result = await mgr.test_plugin(name)
506
+ if result.get("ping"):
507
+ console.print(f"[green]Plugin '{name}' is healthy.[/green]")
508
+ tool_names = result.get("tool_names", [])
509
+ console.print(f" Tools: {len(tool_names)}")
510
+ for t in tool_names:
511
+ console.print(f" - {t}")
512
+ else:
513
+ console.print(f"[red]Plugin '{name}' ping failed.[/red]")
514
+ finally:
515
+ await mgr.stop_all()
516
+
517
+ asyncio.run(_test())
518
+
519
+
520
+ # ── Ollama server commands ────────────────────────────────────────
521
+
522
+ ollama_app = typer.Typer(help="Ollama server management.")
523
+ app.add_typer(ollama_app, name="ollama")
524
+
525
+
526
+ @ollama_app.command("start")
527
+ def ollama_start() -> None:
528
+ """Start the Ollama server in the background."""
529
+ from mita.models.server import is_server_running, start_server
530
+
531
+ cfg = load_config()
532
+ if is_server_running(cfg.ollama.host):
533
+ console.print("[green]Ollama is already running.[/green]")
534
+ return
535
+
536
+ if not start_server(host=cfg.ollama.host, console=console):
537
+ raise typer.Exit(1)
538
+
539
+
540
+ @ollama_app.command("stop")
541
+ def ollama_stop() -> None:
542
+ """Stop the Ollama server (only if started by Mita)."""
543
+ from mita.models.server import is_managed, stop_server
544
+
545
+ if not is_managed():
546
+ console.print("[yellow]Ollama was not started by Mita — not stopping.[/yellow]")
547
+ return
548
+
549
+ stop_server(console=console)
550
+
551
+
552
+ @ollama_app.command("status")
553
+ def ollama_status() -> None:
554
+ """Show Ollama server status."""
555
+ from mita.models.server import find_ollama_binary, is_managed, is_server_running
556
+
557
+ cfg = load_config()
558
+
559
+ binary = find_ollama_binary()
560
+ console.print(f" Binary: {binary or '[red]not found[/red]'}")
561
+ console.print(f" Host: {cfg.ollama.host}")
562
+
563
+ running = is_server_running(cfg.ollama.host)
564
+ if running:
565
+ managed = is_managed()
566
+ label = "running (managed by Mita)" if managed else "running"
567
+ console.print(f" Status: [green]{label}[/green]")
568
+ else:
569
+ console.print(" Status: [red]not running[/red]")
570
+
571
+ console.print(f" Auto-manage: {'yes' if cfg.ollama.auto_manage else 'no'}")
572
+
573
+
574
+ # ── Chat / Ask commands ───────────────────────────────────────────
575
+
576
+
577
+ @app.command("chat")
578
+ def chat_command(
579
+ no_tools: Annotated[
580
+ bool,
581
+ typer.Option("--no-tools", help="Disable all tools."),
582
+ ] = False,
583
+ ) -> None:
584
+ """Open an interactive chat session with the agent."""
585
+ import asyncio
586
+
587
+ from mita.agent.conversation import Conversation
588
+ from mita.agent.loop import run_agent
589
+ from mita.config.loader import load_config as _load_config
590
+ from mita.models.server import ensure_model, ensure_server
591
+ from mita.tools.registry import ToolRegistry, create_default_registry
592
+ from mita.ui.display import get_console
593
+ from mita.ui.repl import repl_loop
594
+
595
+ cfg = _load_config()
596
+ chat_console = get_console()
597
+
598
+ if not ensure_server(
599
+ host=cfg.ollama.host, auto_manage=cfg.ollama.auto_manage, console=chat_console
600
+ ):
601
+ raise typer.Exit(1)
602
+
603
+ if not ensure_model(
604
+ cfg.model.default, host=cfg.ollama.host, timeout=cfg.ollama.timeout, console=chat_console
605
+ ):
606
+ raise typer.Exit(1)
607
+
608
+ from mita.plugins.manager import PluginManager
609
+
610
+ conversation = Conversation()
611
+ registry: ToolRegistry = ToolRegistry() if no_tools else create_default_registry()
612
+
613
+ async def _run_chat() -> None:
614
+ nonlocal conversation
615
+
616
+ # Start MCP plugins at session level (not per-call)
617
+ plugin_mgr: PluginManager | None = None
618
+ if cfg.plugins and not no_tools:
619
+ plugin_mgr = PluginManager(cfg.plugins)
620
+ await plugin_mgr.start_all(console=chat_console)
621
+ await plugin_mgr.register_tools_async(registry)
622
+
623
+ try:
624
+
625
+ async def on_input(user_input: str) -> None:
626
+ nonlocal conversation
627
+ conversation = await run_agent(
628
+ user_input, cfg, chat_console, conversation=conversation, registry=registry
629
+ )
630
+
631
+ def on_clear() -> None:
632
+ nonlocal conversation
633
+ conversation.clear_non_system()
634
+
635
+ await repl_loop(
636
+ chat_console, on_input, on_clear=on_clear, skills_paths=cfg.skills_paths
637
+ )
638
+ finally:
639
+ if plugin_mgr is not None:
640
+ await plugin_mgr.stop_all()
641
+
642
+ asyncio.run(_run_chat())
643
+
644
+
645
+ @app.command("ask")
646
+ def ask_command(
647
+ prompt: Annotated[
648
+ str | None,
649
+ typer.Argument(help="The prompt to send to the agent."),
650
+ ] = None,
651
+ output: Annotated[
652
+ OutputFormat | None,
653
+ typer.Option("--output", "-o", help="Output format: rich, text, or json."),
654
+ ] = None,
655
+ no_tools: Annotated[
656
+ bool,
657
+ typer.Option("--no-tools", help="Disable all tools."),
658
+ ] = False,
659
+ ) -> None:
660
+ """Send a single prompt to the agent (non-interactive)."""
661
+ import asyncio
662
+ import json
663
+
664
+ from mita.agent.conversation import Conversation, Role
665
+ from mita.agent.loop import run_agent
666
+ from mita.config.loader import load_config as _load_config
667
+ from mita.models.server import ensure_model, ensure_server
668
+ from mita.tools.registry import ToolRegistry, create_default_registry
669
+ from mita.ui.display import get_console
670
+
671
+ # Assemble prompt from argument and/or stdin
672
+ parts: list[str] = []
673
+ if prompt is not None:
674
+ parts.append(prompt)
675
+ if not sys.stdin.isatty():
676
+ stdin_text = sys.stdin.read().strip()
677
+ if stdin_text:
678
+ parts.append(stdin_text)
679
+ if not parts:
680
+ console.print("[red]No prompt provided. Pass a prompt argument or pipe via stdin.[/red]")
681
+ raise typer.Exit(1)
682
+
683
+ full_prompt = "\n".join(parts)
684
+
685
+ # Resolve output format: explicit flag wins, else auto-detect
686
+ effective_output = output
687
+ if effective_output is None:
688
+ effective_output = OutputFormat.TEXT if not sys.stdout.isatty() else OutputFormat.RICH
689
+
690
+ cfg = _load_config()
691
+
692
+ # Build the console for this run
693
+ if effective_output == OutputFormat.TEXT:
694
+ ask_console = Console(no_color=True, highlight=False)
695
+ cfg.ui.stream = False
696
+ elif effective_output == OutputFormat.JSON:
697
+ ask_console = Console(file=StringIO(), no_color=True, highlight=False)
698
+ cfg.ui.stream = False
699
+ else:
700
+ ask_console = get_console()
701
+
702
+ if not ensure_server(
703
+ host=cfg.ollama.host, auto_manage=cfg.ollama.auto_manage, console=ask_console
704
+ ):
705
+ raise typer.Exit(1)
706
+
707
+ if not ensure_model(
708
+ cfg.model.default, host=cfg.ollama.host, timeout=cfg.ollama.timeout, console=ask_console
709
+ ):
710
+ raise typer.Exit(1)
711
+
712
+ async def _run_ask() -> Conversation:
713
+ from mita.plugins.manager import PluginManager
714
+
715
+ registry: ToolRegistry = ToolRegistry() if no_tools else create_default_registry()
716
+
717
+ plugin_mgr: PluginManager | None = None
718
+ if cfg.plugins and not no_tools:
719
+ plugin_mgr = PluginManager(cfg.plugins)
720
+ await plugin_mgr.start_all(console=ask_console)
721
+ await plugin_mgr.register_tools_async(registry)
722
+
723
+ try:
724
+ return await run_agent(full_prompt, cfg, ask_console, registry=registry)
725
+ finally:
726
+ if plugin_mgr is not None:
727
+ await plugin_mgr.stop_all()
728
+
729
+ conversation = asyncio.run(_run_ask())
730
+
731
+ # Extract the last assistant message
732
+ last_assistant = ""
733
+ for msg in reversed(conversation.messages):
734
+ if msg.role == Role.ASSISTANT and msg.content:
735
+ last_assistant = msg.content
736
+ break
737
+
738
+ if effective_output == OutputFormat.TEXT:
739
+ print(last_assistant) # noqa: T201
740
+ elif effective_output == OutputFormat.JSON:
741
+ print( # noqa: T201
742
+ json.dumps({"response": last_assistant, "model": cfg.model.default})
743
+ )
744
+
745
+
746
+ # ── Doctor command ────────────────────────────────────────────────
747
+
748
+
749
+ @app.command("doctor")
750
+ def doctor_command() -> None:
751
+ """Check system health and configuration."""
752
+ import platform
753
+
754
+ from mita.ui.display import display_error_with_suggestion
755
+
756
+ doc_console = Console()
757
+
758
+ # 1. Python version
759
+ py_ver = platform.python_version()
760
+ py_tuple = tuple(int(x) for x in py_ver.split(".")[:2])
761
+ if py_tuple >= (3, 11):
762
+ doc_console.print(f" [green]\u2713[/green] Python {py_ver}")
763
+ else:
764
+ doc_console.print(f" [red]\u2717[/red] Python {py_ver}")
765
+ display_error_with_suggestion(
766
+ doc_console,
767
+ "Python 3.11+ is required",
768
+ "Install Python 3.11 or later",
769
+ )
770
+
771
+ # 2. Ollama binary
772
+ from mita.models.server import find_ollama_binary
773
+
774
+ binary = find_ollama_binary()
775
+ if binary:
776
+ doc_console.print(f" [green]\u2713[/green] Ollama binary found ({binary})")
777
+ else:
778
+ doc_console.print(" [red]\u2717[/red] Ollama binary not found")
779
+ display_error_with_suggestion(
780
+ doc_console,
781
+ "Ollama is not installed",
782
+ "Install from https://ollama.com",
783
+ )
784
+
785
+ # 3. Ollama server running
786
+ from mita.models.server import is_server_running
787
+
788
+ cfg = load_config()
789
+ if is_server_running(cfg.ollama.host):
790
+ doc_console.print(" [green]\u2713[/green] Ollama server running")
791
+ else:
792
+ doc_console.print(" [red]\u2717[/red] Ollama not running")
793
+ display_error_with_suggestion(
794
+ doc_console,
795
+ "Ollama server is not running",
796
+ "Run 'mita ollama start' or 'ollama serve'",
797
+ )
798
+
799
+ # 4. Default model installed
800
+ _check_model_installed(doc_console, cfg.model.default, "Default model", cfg)
801
+
802
+ # 5. Embedding model installed
803
+ _check_model_installed(doc_console, cfg.model.embedding, "Embedding model", cfg)
804
+
805
+ # 6. Config loads without error
806
+ try:
807
+ load_config()
808
+ doc_console.print(" [green]\u2713[/green] Config loaded successfully")
809
+ except Exception as exc:
810
+ doc_console.print(" [red]\u2717[/red] Config load error")
811
+ display_error_with_suggestion(
812
+ doc_console,
813
+ f"Config error: {exc}",
814
+ "Check ~/.config/mita/config.toml and .mita/settings.toml",
815
+ )
816
+
817
+ # 7. Memory files discoverable
818
+ from mita.memory.discovery import discover_memory_files
819
+
820
+ mem_files = discover_memory_files()
821
+ if mem_files:
822
+ doc_console.print(f" [green]\u2713[/green] Memory files found ({len(mem_files)})")
823
+ else:
824
+ doc_console.print(" [yellow]![/yellow] No MITA.md memory files found")
825
+
826
+ # 8. Index exists
827
+ from pathlib import Path
828
+
829
+ from mita.index.store import IndexStore
830
+
831
+ index_dir = Path.cwd() / ".mita" / "index"
832
+ store = IndexStore(index_dir)
833
+ if store.exists():
834
+ doc_console.print(" [green]\u2713[/green] Codebase index exists")
835
+ else:
836
+ doc_console.print(" [yellow]![/yellow] No codebase index")
837
+ display_error_with_suggestion(
838
+ doc_console,
839
+ "Codebase index not built",
840
+ "Run 'mita index build' to create it",
841
+ )
842
+
843
+
844
+ def _check_model_installed(doc_console: Console, model_name: str, label: str, cfg: object) -> None:
845
+ """Check if an Ollama model is installed and print status."""
846
+ from mita.config.schema import MitaConfig
847
+ from mita.models.ollama_client import OllamaClient
848
+ from mita.models.server import is_server_running
849
+ from mita.ui.display import display_error_with_suggestion
850
+
851
+ assert isinstance(cfg, MitaConfig)
852
+
853
+ if not is_server_running(cfg.ollama.host):
854
+ doc_console.print(
855
+ f" [yellow]![/yellow] {label} ({model_name}) — cannot check (server not running)"
856
+ )
857
+ return
858
+
859
+ try:
860
+ client = OllamaClient(host=cfg.ollama.host, timeout=cfg.ollama.timeout)
861
+ installed = client.list_models()
862
+ found = any(
863
+ m.name == model_name
864
+ or m.name == f"{model_name}:latest"
865
+ or m.name.split(":")[0] == model_name.split(":")[0]
866
+ for m in installed
867
+ )
868
+ if found:
869
+ doc_console.print(f" [green]\u2713[/green] {label} ({model_name}) installed")
870
+ else:
871
+ doc_console.print(f" [red]\u2717[/red] {label} ({model_name}) not installed")
872
+ display_error_with_suggestion(
873
+ doc_console,
874
+ f"{label} '{model_name}' is not installed",
875
+ f"Run 'mita models pull {model_name}'",
876
+ )
877
+ except (ConnectionError, OSError):
878
+ doc_console.print(f" [yellow]![/yellow] {label} ({model_name}) — cannot check")
879
+
880
+
881
+ # ── Helpers ───────────────────────────────────────────────────────
882
+
883
+
884
+ def _config_to_toml(cfg: object) -> str:
885
+ """Convert a MitaConfig to a TOML-formatted string for display."""
886
+ from mita.config.schema import MitaConfig
887
+
888
+ assert isinstance(cfg, MitaConfig)
889
+ data = cfg.model_dump()
890
+ lines: list[str] = []
891
+ _dict_to_toml(data, lines, prefix="")
892
+ return "\n".join(lines)
893
+
894
+
895
+ def _dict_to_toml(data: dict, lines: list[str], prefix: str) -> None: # type: ignore[type-arg]
896
+ """Recursively format a dict as TOML."""
897
+ scalars = {k: v for k, v in data.items() if not isinstance(v, (dict, list))}
898
+ dicts = {k: v for k, v in data.items() if isinstance(v, dict)}
899
+ lists = {k: v for k, v in data.items() if isinstance(v, list)}
900
+
901
+ for k, v in scalars.items():
902
+ lines.append(f"{k} = {_toml_value(v)}")
903
+
904
+ for k, v in lists.items():
905
+ if v and isinstance(v[0], dict):
906
+ # Array of tables
907
+ for item in v:
908
+ section = f"{prefix}{k}" if prefix else k
909
+ lines.append(f"\n[[{section}]]")
910
+ _dict_to_toml(item, lines, prefix=f"{section}.")
911
+ else:
912
+ lines.append(f"{k} = {_toml_value(v)}")
913
+
914
+ for k, v in dicts.items():
915
+ section = f"{prefix}{k}" if prefix else k
916
+ lines.append(f"\n[{section}]")
917
+ _dict_to_toml(v, lines, prefix=f"{section}.")
918
+
919
+
920
+ def _toml_value(v: object) -> str:
921
+ """Format a Python value as a TOML value string."""
922
+ if isinstance(v, bool):
923
+ return "true" if v else "false"
924
+ if isinstance(v, str):
925
+ return f'"{v}"'
926
+ if isinstance(v, (int, float)):
927
+ return str(v)
928
+ if isinstance(v, list):
929
+ items = ", ".join(_toml_value(i) for i in v)
930
+ return f"[{items}]"
931
+ return repr(v)
932
+
933
+
934
+ def _write_toml(path: object, data: dict) -> None: # type: ignore[type-arg]
935
+ """Write a dict back to a TOML file."""
936
+ from pathlib import Path
937
+
938
+ lines: list[str] = []
939
+ _dict_to_toml(data, lines, prefix="")
940
+ Path(str(path)).write_text("\n".join(lines) + "\n")