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/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")
|