minacode 0.12.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.
minacode/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """minacode: A small terminal coding agent written in Python.
2
+
3
+ The implementation lives in focused submodules (``base``, ``session``,
4
+ ``skill``, ``mcp``, ``tools``, ``engine``, ``tui``) plus a ``__main__`` entry
5
+ point. The public names are
6
+ re-exported here so ``import minacode`` keeps exposing the same namespace the
7
+ single-file module used to provide.
8
+ """
9
+
10
+ from minacode.tui import *
11
+ from minacode.base import __version__
12
+
13
+
14
+ def __getattr__(name: str):
15
+ # Lazily expose the entry point so importing minacode (and running `python -m minacode`)
16
+ # does not eagerly import __main__, which would raise a duplicate-module RuntimeWarning.
17
+ if name == "main":
18
+ from minacode.__main__ import main
19
+
20
+ return main
21
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
minacode/__main__.py ADDED
@@ -0,0 +1,66 @@
1
+ """minacode entry point: command-line argument parsing and dispatch.
2
+
3
+ Invoked through the ``minacode`` console script or ``python -m minacode``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from minacode.tui import *
9
+ from minacode.base import __version__
10
+ import argparse
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ if sys.platform == "win32":
15
+ print("Error: minacode does not support native Windows; use WSL instead.", file=sys.stderr)
16
+ return 1
17
+
18
+ parser = argparse.ArgumentParser(prog="minacode")
19
+ parser.add_argument("--config", default=None, help="Path to config TOML")
20
+ parser.add_argument("--init-config", action="store_true", help="Create a default config file")
21
+ parser.add_argument("--yolo", action="store_true", help="Skip confirmations for mutating tools")
22
+ parser.add_argument(
23
+ "--theme", choices=["auto", "light", "dark"], default="", help="Color theme (defaults to runtime.theme, then auto-detect via COLORFGBG)"
24
+ )
25
+ resume = parser.add_mutually_exclusive_group()
26
+ resume.add_argument("--resume", default="", nargs="?", const="latest", help='Resume a session by UID, or "latest"/"last" for this project\'s most recent')
27
+ resume.add_argument("-c", "--last", "--latest", dest="continue_project", action="store_true", help="Resume the latest session in the current project")
28
+ parser.add_argument("-v", "--version", action="store_true", help="Show version")
29
+ args = parser.parse_args(argv)
30
+ if args.version:
31
+ print(__version__)
32
+ return 0
33
+ try:
34
+ if args.init_config:
35
+ path, created = ConfigFile.init(args.config)
36
+ print(("Created" if created else "Exists") + " config: " + path)
37
+ return 0
38
+ if args.resume or args.continue_project:
39
+ data = ConfigFile.load(args.config)
40
+ config = Config.from_dict(data)
41
+ session = Session.load_snapshot(
42
+ args.resume or "latest",
43
+ config=config,
44
+ settings=RuntimeSettings.from_dict(data, yolo=args.yolo, theme=args.theme),
45
+ cwd=os.getcwd(),
46
+ )
47
+ else:
48
+ session = Session.from_config_file(path=args.config, yolo=args.yolo, theme=args.theme)
49
+ Theme.set_mode(Theme.resolve(session.settings.theme))
50
+ command_loop = CommandLoop(Agent(session))
51
+ try:
52
+ return command_loop.run()
53
+ finally:
54
+ command_loop.close_background_output()
55
+ if session.mcp is not None:
56
+ session.mcp.close()
57
+ except ConfigError as error:
58
+ print("ConfigError: " + str(error), file=sys.stderr)
59
+ return 2
60
+ except MinacodeError as error:
61
+ print("Error: " + str(error), file=sys.stderr)
62
+ return 1
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
minacode/base.py ADDED
@@ -0,0 +1,567 @@
1
+ """minacode base: errors, text helpers, configuration, and shared data types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import sys
11
+ import time
12
+ import tomllib
13
+ import concurrent.futures
14
+ from dataclasses import dataclass, field
15
+ from enum import auto
16
+ from typing import Any, ClassVar
17
+ from urllib.parse import urlparse
18
+
19
+ import anthropic
20
+ import openai
21
+ from openai import OpenAI
22
+ from prompt_toolkit.utils import get_cwidth
23
+
24
+ try:
25
+ import pygments
26
+ from pygments.token import Token
27
+ except ImportError: # pragma: no cover - optional highlighting dependency
28
+ pygments = None
29
+ Token = None # keep the name defined so class-body/token lookups don't NameError
30
+
31
+ __version__ = "0.12.0"
32
+
33
+ Json = dict[str, Any]
34
+
35
+
36
+ HTTP_USER_AGENT = "minacode/" + __version__
37
+ logging.getLogger("fastmcp.client.auth.oauth").setLevel(logging.WARNING)
38
+ # Refresh failures / re-auth fall back to minacode's own handling, which surfaces an
39
+ # actionable "authentication required" message; suppress this logger's ERROR-level
40
+ # traceback spam (incl. the RuntimeError minacode raises as control flow).
41
+ logging.getLogger("mcp.client.auth.oauth2").setLevel(logging.CRITICAL)
42
+ DEFAULT_MAX_CONTEXT_TOKENS = 240 * 1024
43
+ MAX_TOOL_OUTPUT_TOKENS = 6_000
44
+ MODEL_REQUEST_RETRIES = 2
45
+ PROVIDER_API_CHOICES = ("auto", "chat", "anthropic")
46
+ REASONING_LEVELS = ("minimal", "low", "medium", "high", "xhigh")
47
+ REASONING_CHOICES = ("off", *REASONING_LEVELS)
48
+ CHAT_REASONING_CHOICES = ("auto", "off", "reasoning", "reasoning_effort", "thinking", "enable_thinking")
49
+ ANTHROPIC_DEFAULT_MAX_TOKENS = 16_384
50
+ DEEPSEEK_DEFAULT_MAX_TOKENS = 32_768
51
+ DEFAULT_OUTPUT_RESERVE_TOKENS = ANTHROPIC_DEFAULT_MAX_TOKENS
52
+ MIN_CONTEXT_SAFETY_TOKENS = 4_096
53
+ CHAT_REASONING_EFFORT_VALUES: dict[str, dict[str, str | int]] = {
54
+ "thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "max", "xhigh": "max"},
55
+ "enable_thinking": {"minimal": 256, "low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384},
56
+ }
57
+ SELECTION_BACK = object()
58
+ SELECTION_FREE_TEXT = object()
59
+ DISMISSED = "(The user dismissed the question without answering.)"
60
+
61
+
62
+ class MinacodeError(Exception): ...
63
+
64
+
65
+ class ConfigError(MinacodeError): ...
66
+
67
+
68
+ class ModelError(MinacodeError): ...
69
+
70
+
71
+ class ModelRequestRetry(MinacodeError): ...
72
+
73
+
74
+ class ToolError(MinacodeError): ...
75
+
76
+
77
+ class Text:
78
+ BASE36: ClassVar[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
79
+
80
+ @staticmethod
81
+ def clean(text: str) -> str:
82
+ return text.encode("utf-8", errors="replace").decode("utf-8")
83
+
84
+ @classmethod
85
+ def base36(cls, value: int) -> str:
86
+ out = ""
87
+ while value:
88
+ value, digit = divmod(value, 36)
89
+ out = cls.BASE36[digit] + out
90
+ return out or "0"
91
+
92
+ @classmethod
93
+ def value(cls, value: Any) -> Any:
94
+ if isinstance(value, str):
95
+ return cls.clean(value)
96
+ if isinstance(value, dict):
97
+ return {cls.clean(str(key)): cls.value(item) for key, item in value.items()}
98
+ if isinstance(value, (list, tuple)):
99
+ return [cls.value(item) for item in value]
100
+ return value
101
+
102
+ @staticmethod
103
+ def elapsed_since(started_at: float, *, precise: bool = False) -> str:
104
+ raw = max(0.0, time.monotonic() - started_at) if started_at else 0.0
105
+ if raw < 60:
106
+ return f"{raw:.1f}s" if precise else f"{int(raw)}s"
107
+ minutes, seconds = divmod(int(raw), 60)
108
+ return f"{minutes}m{seconds:02d}s"
109
+
110
+ @staticmethod
111
+ def clip_width(text: str, width: int) -> str:
112
+ width = max(0, width)
113
+ if get_cwidth(text) <= width:
114
+ return text
115
+ ellipsis = "." * min(3, width)
116
+ available = width - get_cwidth(ellipsis)
117
+ clipped = []
118
+ used = 0
119
+ for char in text:
120
+ char_width = max(0, get_cwidth(char))
121
+ if used + char_width > available:
122
+ break
123
+ clipped.append(char)
124
+ used += char_width
125
+ return "".join(clipped).rstrip() + ellipsis
126
+
127
+ @staticmethod
128
+ def wrap_styled(
129
+ prefix: list[tuple[str, str]],
130
+ continuation: list[tuple[str, str]],
131
+ content: list[tuple[str, str]],
132
+ width: int | None = None,
133
+ ) -> list[list[tuple[str, str]]]:
134
+ logical_lines: list[list[tuple[str, str, int]]] = [[]]
135
+ for style, text in content:
136
+ for char in text:
137
+ if char == "\n":
138
+ logical_lines.append([])
139
+ else:
140
+ logical_lines[-1].append((style, char, get_cwidth(char)))
141
+
142
+ def row_segments(row_prefix: list[tuple[str, str]], cells: list[tuple[str, str, int]]) -> list[tuple[str, str]]:
143
+ row = list(row_prefix)
144
+ for style, char, _char_width in cells:
145
+ if row and row[-1][0] == style:
146
+ row[-1] = (style, row[-1][1] + char)
147
+ else:
148
+ row.append((style, char))
149
+ return row
150
+
151
+ rows: list[list[tuple[str, str]]] = []
152
+ row_prefix = prefix
153
+ for logical in logical_lines:
154
+ remaining = logical
155
+ while True:
156
+ prefix_width = sum(get_cwidth(text) for _style, text in row_prefix)
157
+ available = max(1, width - prefix_width) if width else None
158
+ if available is None or sum(cell_width for _style, _char, cell_width in remaining) <= available:
159
+ rows.append(row_segments(row_prefix, remaining))
160
+ break
161
+ used = 0
162
+ fit = 0
163
+ while fit < len(remaining) and used + remaining[fit][2] <= available:
164
+ used += remaining[fit][2]
165
+ fit += 1
166
+ fit = max(1, fit)
167
+ whitespace = max((index for index in range(fit) if remaining[index][1].isspace()), default=-1)
168
+ cut = whitespace if whitespace > 0 else fit
169
+ rows.append(row_segments(row_prefix, remaining[:cut]))
170
+ remaining = remaining[cut + 1 :] if whitespace > 0 else remaining[cut:]
171
+ row_prefix = continuation
172
+ row_prefix = continuation
173
+ return rows
174
+
175
+
176
+ @dataclass
177
+ class ProviderConfig:
178
+ # fmt: off
179
+ PROFILES: ClassVar[dict[str, dict[str, Any]]] = {
180
+ "api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),), "strict_tools": True},
181
+ "openrouter.ai": {"chat_reasoning": "reasoning"},
182
+ "opencode.ai": {"api_rules": (("anthropic", ("claude-", "qwen3.")),), "chat_reasoning_rules": (("reasoning", ("deepseek-v4",)),)},
183
+ "api.deepseek.com": {"chat_reasoning": "thinking", "max_tokens": DEEPSEEK_DEFAULT_MAX_TOKENS, "prompt_cache_key": False, "strict_tools": True, "strict_beta": True},
184
+ }
185
+ # fmt: on
186
+
187
+ url: str = ""
188
+ key: str = ""
189
+ model: str = ""
190
+ api: str = "auto"
191
+ prompt_cache_key: str = "auto"
192
+ available_models: tuple[str, ...] = ()
193
+ temperature: float | None = None
194
+ max_tokens: int = 0
195
+ strict_tools: bool = False
196
+ reasoning: str = "medium"
197
+ chat_reasoning: str = "auto"
198
+ timeout: int = 180
199
+ extra_body: Json = field(default_factory=dict)
200
+
201
+ @classmethod
202
+ def from_dict(cls, data: Json) -> "ProviderConfig":
203
+ api = Config.str(data, "api", "auto")
204
+ prompt_cache_key = cls.clean_prompt_cache_key(Config.str(data, "prompt_cache_key", "auto"))
205
+ reasoning = Config.str(data, "reasoning", "medium")
206
+ chat_reasoning = Config.str(data, "chat_reasoning", "auto")
207
+ for key, value, choices in (
208
+ ("api", api, PROVIDER_API_CHOICES),
209
+ ("reasoning", reasoning, REASONING_CHOICES),
210
+ ("chat_reasoning", chat_reasoning, CHAT_REASONING_CHOICES),
211
+ ):
212
+ if value not in choices:
213
+ raise ConfigError("provider." + key + " must be one of " + ", ".join(choices))
214
+ return cls(
215
+ url=Config.str(data, "url"),
216
+ key=Config.str(data, "key"),
217
+ model=Config.str(data, "model"),
218
+ api=api,
219
+ prompt_cache_key=prompt_cache_key,
220
+ available_models=Config.str_tuple(data, "available_models"),
221
+ temperature=Config.float(data, "temperature", None),
222
+ max_tokens=max(0, Config.int(data, "max_tokens", 0)),
223
+ strict_tools=Config.bool(data, "strict_tools", False),
224
+ reasoning=reasoning,
225
+ chat_reasoning=chat_reasoning,
226
+ timeout=Config.int(data, "timeout", 180),
227
+ extra_body=Config.table(data, "extra_body"),
228
+ )
229
+
230
+ def _stripped_url(self) -> str:
231
+ url = self.url.rstrip("/")
232
+ return url.removesuffix("/chat/completions").removesuffix("/responses").removesuffix("/messages")
233
+
234
+ def base_url(self) -> str:
235
+ # Strict tool calling is a beta feature on some hosts (DeepSeek); route to /beta only when active.
236
+ url = self._stripped_url()
237
+ return url + "/beta" if self.resolved_strict_tools() and self._profile().get("strict_beta") and not url.endswith("/beta") else url
238
+
239
+ def host(self) -> str:
240
+ return (urlparse(self._stripped_url()).hostname or "").lower()
241
+
242
+ def _profile(self) -> Json:
243
+ return self.PROFILES.get(self.host()) or {}
244
+
245
+ def resolved_chat_reasoning(self) -> str:
246
+ return self.profile_value(self.chat_reasoning, "off", "chat_reasoning", "chat_reasoning_rules")
247
+
248
+ def resolved_api(self) -> str:
249
+ return self.profile_value(self.api, "chat", "api", "api_rules")
250
+
251
+ def profile_value(self, configured: str, default: str, profile_attr: str, rules_attr: str) -> str:
252
+ if configured != "auto":
253
+ return configured
254
+ if not (profile := self._profile()):
255
+ return default
256
+ model = self.model.lower()
257
+ for value, prefixes in profile.get(rules_attr, ()):
258
+ if any(model.startswith(prefix) for prefix in prefixes):
259
+ return str(value)
260
+ return str(profile.get(profile_attr, default))
261
+
262
+ def reasoning_effort(self) -> str:
263
+ return self.reasoning if self.reasoning in REASONING_LEVELS else "medium"
264
+
265
+ def resolved_max_tokens(self) -> int:
266
+ # Generic OpenAI-compatible providers keep their own server-side cap; only opted-in profiles get a ceiling.
267
+ return self.max_tokens or int(self._profile().get("max_tokens", 0))
268
+
269
+ def output_token_budget(self) -> int:
270
+ return self.resolved_max_tokens() or DEFAULT_OUTPUT_RESERVE_TOKENS
271
+
272
+ def supports_prompt_cache_key(self) -> bool:
273
+ # Default on for unknown OpenAI-compatible hosts (status quo); profiles opt out
274
+ # (e.g. DeepSeek caches automatically by prefix and ignores the key).
275
+ return bool(self._profile().get("prompt_cache_key", True))
276
+
277
+ def supports_strict_tools(self) -> bool:
278
+ return bool(self._profile().get("strict_tools"))
279
+
280
+ def resolved_strict_tools(self) -> bool:
281
+ # Only emit strict schemas on the chat path of a host known to support strict mode.
282
+ return self.strict_tools and self.supports_strict_tools() and self.resolved_api() == "chat"
283
+
284
+ @staticmethod
285
+ def clean_prompt_cache_key(value: str) -> str:
286
+ value = value.strip()
287
+ if not value:
288
+ return "auto"
289
+ lower = value.lower()
290
+ if lower in {"auto", "off"}:
291
+ return lower
292
+ if len(value) > 64 or any(char.isspace() for char in value):
293
+ raise ConfigError("provider.prompt_cache_key must be auto, off, or a stable key up to 64 chars without whitespace")
294
+ return value
295
+
296
+
297
+ @dataclass
298
+ class RuntimeSettings:
299
+ shell_timeout: int = 60
300
+ # Bash foreground wait budget: if the command hasn't exited within this many seconds the running
301
+ # process is promoted to a background job (see BashTool.stream_process) and control returns to
302
+ # the model with a partial-output payload. Set to 0 to disable promotion (fall back to killing
303
+ # on shell_timeout).
304
+ bash_wait_timeout: int = 10
305
+ max_steps: int = 200
306
+ max_context_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS
307
+ session_retention_days: int = 7
308
+ # Max read-only tool calls from one model batch to execute concurrently; 1 disables parallelism.
309
+ max_parallel_tools: int = 4
310
+ yolo: bool = False
311
+ theme: str = "auto"
312
+
313
+ @classmethod
314
+ def from_dict(cls, data: Json, *, yolo: bool = False, theme: str = "") -> "RuntimeSettings":
315
+ runtime = Config.table(data, "runtime")
316
+ return cls(
317
+ shell_timeout=Config.int(runtime, "shell_timeout", 60),
318
+ bash_wait_timeout=max(0, Config.int(runtime, "bash_wait_timeout", 10)),
319
+ max_steps=max(1, Config.int(runtime, "max_agent_steps", 200)),
320
+ max_context_tokens=max(1, Config.int(runtime, "max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)),
321
+ max_parallel_tools=max(1, Config.int(runtime, "max_parallel_tools", 4)),
322
+ session_retention_days=max(0, Config.int(runtime, "session_retention_days", 7)),
323
+ yolo=yolo or Config.bool(runtime, "yolo", False),
324
+ theme=theme or Config.str(runtime, "theme", "auto"),
325
+ )
326
+
327
+
328
+ @dataclass
329
+ class Config:
330
+ active_provider: str = "default"
331
+ providers: dict[str, ProviderConfig] = field(default_factory=lambda: {"default": ProviderConfig()})
332
+ data_dir: str = "~/.minacode"
333
+ mcp: Json = field(default_factory=dict)
334
+
335
+ # Backward compatibility: the data dir moved from ~/.nanocode to ~/.minacode.
336
+ LEGACY_DATA_DIR: ClassVar[str] = "~/.nanocode"
337
+
338
+ def __post_init__(self) -> None:
339
+ # When the data dir is still the new default but does not exist yet and the legacy
340
+ # ~/.nanocode dir does, keep using the legacy dir so existing sessions, skills, and
341
+ # cache are found without a migration step.
342
+ if (
343
+ self.data_dir == "~/.minacode"
344
+ and not os.path.exists(os.path.expanduser(self.data_dir))
345
+ and os.path.exists(os.path.expanduser(self.LEGACY_DATA_DIR))
346
+ ):
347
+ self.data_dir = self.LEGACY_DATA_DIR
348
+
349
+ @property
350
+ def provider(self) -> ProviderConfig:
351
+ return self.providers[self.active_provider]
352
+
353
+ @classmethod
354
+ def from_dict(cls, data: Json) -> "Config":
355
+ provider_root = cls.table(data, "provider")
356
+ active = cls.str(provider_root, "active", "default")
357
+ providers = {name: ProviderConfig.from_dict(value) for name, value in provider_root.items() if name != "active" and isinstance(value, dict)}
358
+ if not providers:
359
+ providers = {active: ProviderConfig.from_dict(provider_root)}
360
+ if active not in providers:
361
+ raise ConfigError(f"provider.active `{active}` does not exist")
362
+ paths = cls.table(data, "paths")
363
+ return cls(active_provider=active, providers=providers, data_dir=cls.str(paths, "data_dir", "~/.minacode"), mcp=cls.table(data, "mcp"))
364
+
365
+ @staticmethod
366
+ def table(data: Json, key: str) -> Json:
367
+ return value if isinstance((value := data.get(key)), dict) else {}
368
+
369
+ @staticmethod
370
+ def str(data: Json, key: str, default: str = "") -> str:
371
+ return default if (value := data.get(key)) is None else str(value)
372
+
373
+ @staticmethod
374
+ def str_tuple(data: Json, key: str) -> tuple[str, ...]:
375
+ value = data.get(key)
376
+ if value is None:
377
+ return ()
378
+ if isinstance(value, str):
379
+ return tuple(item.strip() for item in value.split(",") if item.strip())
380
+ if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
381
+ return tuple(value)
382
+ raise ConfigError(f"config value `{key}` must be a string list")
383
+
384
+ @staticmethod
385
+ def bool(data: Json, key: str, default: bool = False) -> bool:
386
+ value = data.get(key)
387
+ if value is None:
388
+ return default
389
+ if isinstance(value, bool):
390
+ return value
391
+ lower = value.lower() if isinstance(value, str) else ""
392
+ if lower in {"on", "true", "yes", "1", "off", "false", "no", "0"}:
393
+ return lower in {"on", "true", "yes", "1"}
394
+ raise ConfigError(f"config value `{key}` must be boolean")
395
+
396
+ @staticmethod
397
+ def int(data: Json, key: str, default: int) -> int:
398
+ value = data.get(key)
399
+ if value is None:
400
+ return default
401
+ if isinstance(value, bool) or not isinstance(value, int):
402
+ raise ConfigError(f"config value `{key}` must be integer")
403
+ return value
404
+
405
+ @staticmethod
406
+ def float(data: Json, key: str, default: float | None) -> float | None:
407
+ value = data.get(key)
408
+ if value is None:
409
+ return default
410
+ if value is False or (isinstance(value, str) and value.lower() == "off"):
411
+ return None
412
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
413
+ raise ConfigError(f"config value `{key}` must be number or off")
414
+ return float(value)
415
+
416
+
417
+ class ConfigFile:
418
+ DEFAULT_PATH: ClassVar[str] = os.path.join(os.path.expanduser("~"), ".minacode", "config.toml")
419
+ LEGACY_PATH: ClassVar[str] = os.path.join(os.path.expanduser("~"), ".nanocode", "config.toml")
420
+ # Only the provider block is required; every other key falls back to its built-in default, so the
421
+ # commented lines below just document the common knobs and their defaults.
422
+ DEFAULT_TEXT: ClassVar[str] = """# minacode configuration — unset keys use built-in defaults.
423
+
424
+ [provider]
425
+ active = "default"
426
+
427
+ [provider.default]
428
+ url = ""
429
+ key = ""
430
+ model = ""
431
+ # api = "auto" # auto | anthropic | openai | ...
432
+ # reasoning = "medium"
433
+ # timeout = 180
434
+ # available_models = ["gpt-5", "gpt-5-mini"]
435
+
436
+ # [runtime] # optional overrides (defaults shown)
437
+ # yolo = false
438
+ # max_context_tokens = 245760 # 240K
439
+ # max_agent_steps = 200
440
+ # shell_timeout = 60
441
+
442
+ # [mcp.example] # url (+ auth = "oauth") for remote, or command/args for stdio
443
+ # url = "https://example.com/mcp"
444
+ # auto_connect = false
445
+ """
446
+
447
+ @classmethod
448
+ def resolve_path(cls, path: str | None) -> str:
449
+ if path:
450
+ return os.path.expanduser(path)
451
+ # Backward compatibility: read the legacy ~/.nanocode/config.toml when the new
452
+ # ~/.minacode/config.toml does not exist yet.
453
+ if not os.path.exists(cls.DEFAULT_PATH) and os.path.exists(cls.LEGACY_PATH):
454
+ return cls.LEGACY_PATH
455
+ return cls.DEFAULT_PATH
456
+
457
+ @classmethod
458
+ def init(cls, path: str | None = None) -> tuple[str, bool]:
459
+ config_path = cls.resolve_path(path)
460
+ if os.path.exists(config_path):
461
+ return config_path, False
462
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
463
+ with open(config_path, "w", encoding="utf-8") as file:
464
+ file.write(cls.DEFAULT_TEXT)
465
+ return config_path, True
466
+
467
+ @classmethod
468
+ def load(cls, path: str | None = None) -> Json:
469
+ config_path = cls.resolve_path(path)
470
+ try:
471
+ with open(config_path, "rb") as file:
472
+ data = tomllib.load(file)
473
+ except FileNotFoundError as error:
474
+ raise ConfigError(f"config not found: {config_path}; run --init-config") from error
475
+ except tomllib.TOMLDecodeError as error:
476
+ raise ConfigError(f"invalid config {config_path}: {error}") from error
477
+ return data if isinstance(data, dict) else {}
478
+
479
+
480
+ @dataclass
481
+ class ModelUsage:
482
+ calls: int = 0
483
+ prompt_tokens: int = 0
484
+ completion_tokens: int = 0
485
+ total_tokens: int = 0
486
+ cached_prompt_tokens: int = 0
487
+ last_prompt_tokens: int = 0
488
+ last_cached_prompt_tokens: int = 0
489
+
490
+ @staticmethod
491
+ def field(usage: Any, *paths: str) -> int:
492
+ """First present dotted path in `usage` (dict keys or attributes) as an int, else 0."""
493
+ for path in paths:
494
+ raw = usage
495
+ for key in path.split("."):
496
+ raw = raw.get(key) if isinstance(raw, dict) else getattr(raw, key, None)
497
+ if raw is None:
498
+ break
499
+ else:
500
+ return int(raw or 0)
501
+ return 0
502
+
503
+ def add(self, usage: Any) -> None:
504
+ self.calls += 1
505
+ prompt_tokens = self.field(usage, "prompt_tokens", "input_tokens")
506
+ completion_tokens = self.field(usage, "completion_tokens", "output_tokens")
507
+ total_tokens = self.field(usage, "total_tokens") or prompt_tokens + completion_tokens
508
+ # fmt: off
509
+ cached_tokens = self.field(usage, "prompt_cache_hit_tokens", "cached_tokens", "cache_read_input_tokens", "prompt_tokens_details.cached_tokens", "input_tokens_details.cached_tokens")
510
+ # fmt: on
511
+ self.prompt_tokens += prompt_tokens
512
+ self.completion_tokens += completion_tokens
513
+ self.total_tokens += total_tokens
514
+ self.cached_prompt_tokens += cached_tokens
515
+ self.last_prompt_tokens = prompt_tokens
516
+ self.last_cached_prompt_tokens = cached_tokens
517
+
518
+
519
+ @dataclass
520
+ class UpdateStatus:
521
+ latest: str = ""
522
+ checking: bool = False
523
+ error: str = ""
524
+
525
+ def newer_than(self, current: str) -> bool:
526
+ current_version = self.version_tuple(current)
527
+ latest_version = self.version_tuple(self.latest)
528
+ return bool(current_version and latest_version and latest_version > current_version)
529
+
530
+ @staticmethod
531
+ def version_tuple(value: str) -> tuple[int, ...]:
532
+ match = re.match(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?", value)
533
+ return tuple(int(part or 0) for part in match.groups()) if match else ()
534
+
535
+
536
+ @dataclass
537
+ class SystemInfo:
538
+ # fmt: off
539
+ COMMANDS: ClassVar[tuple[str, ...]] = (
540
+ "bash", "git", "rg", "sed", "grep", "find", "awk", "python3", "jq", "xargs", "cat", "head", "tail", "wc",
541
+ "sort", "uniq", "make", "cmake", "gcc", "g++", "clang", "clang++", "node", "npm", "uv", "pytest",
542
+ )
543
+ # fmt: on
544
+
545
+ cwd: str
546
+ os: str
547
+ arch: str
548
+ commands: tuple[str, ...]
549
+
550
+ @classmethod
551
+ def detect(cls, cwd: str) -> "SystemInfo":
552
+ return cls(
553
+ cwd=cwd,
554
+ os=platform.system() or sys.platform,
555
+ arch=platform.machine() or "unknown",
556
+ commands=tuple(name for name in cls.COMMANDS if shutil.which(name)),
557
+ )
558
+
559
+
560
+ @dataclass
561
+ class ToolCall:
562
+ id: str
563
+ name: str
564
+ args: list[Any]
565
+ # A malformed-argument error captured while parsing the call. Deferred so it surfaces as a
566
+ # tool result the model can correct from, instead of aborting the whole turn at parse time.
567
+ error: str = ""