diff --git a/docs/architecture.md b/docs/architecture.md
index 9f6fca8..bb88862 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -274,7 +274,8 @@ flowchart LR
subgraph toml_contents["takopi.toml"]
direction TB
- global["bot_token
chat_id
default_engine"]
+ global["transport
default_engine"]
+ telegram_cfg["[transports.telegram]
bot_token = ...
chat_id = ..."]
claude_cfg["[claude]
model = ..."]
codex_cfg["[codex]
model = ..."]
projects_cfg["[projects.alias]
path = ...
worktrees_dir = ...
default_engine = ..."]
diff --git a/docs/developing.md b/docs/developing.md
index 84a7c14..ce4cd93 100644
--- a/docs/developing.md
+++ b/docs/developing.md
@@ -229,11 +229,18 @@ Self-documenting msgspec schemas for decoding engine JSONL streams.
class ConfigError(RuntimeError): ...
```
-### `telegram/config.py` - Configuration loading
+### `settings.py` - Settings loading
```python
-def load_telegram_config() -> tuple[dict, Path]:
- # Loads ~/.takopi/takopi.toml
+def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
+ # Loads ~/.takopi/takopi.toml (TOML + env), validates via pydantic-settings
+```
+
+### `config_store.py` - Raw TOML read/write
+
+```python
+def read_raw_toml(path: Path) -> dict:
+ # Loads TOML for merge/update without clobbering extra sections
```
### `logging.py` - Secure logging setup
diff --git a/docs/projects.md b/docs/projects.md
index bfe2473..6517ff8 100644
--- a/docs/projects.md
+++ b/docs/projects.md
@@ -20,6 +20,9 @@ All config lives in `~/.takopi/takopi.toml`.
```toml
default_engine = "codex" # optional
default_project = "z80" # optional
+transport = "telegram" # required
+
+[transports.telegram]
bot_token = "..." # required
chat_id = 123 # required
diff --git a/pyproject.toml b/pyproject.toml
index ffff740..40b1049 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,8 @@ dependencies = [
"httpx>=0.28.1",
"markdown-it-py",
"msgspec>=0.20.0",
+ "pydantic>=2.12.5",
+ "pydantic-settings>=2.12.0",
"questionary>=2.1.1",
"rich>=14.2.0",
"structlog>=25.5.0",
diff --git a/readme.md b/readme.md
index 2ae466f..cd76a2b 100644
--- a/readme.md
+++ b/readme.md
@@ -53,6 +53,9 @@ global config `~/.takopi/takopi.toml`
```toml
default_engine = "codex"
+transport = "telegram"
+
+[transports.telegram]
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
chat_id = 123456789
@@ -78,6 +81,8 @@ provider = "openai"
extra_args = ["--no-color"]
```
+note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`.
+
## projects
register the current repo as a project alias:
diff --git a/src/takopi/cli.py b/src/takopi/cli.py
index 127d349..b0f13f0 100644
--- a/src/takopi/cli.py
+++ b/src/takopi/cli.py
@@ -11,17 +11,19 @@ import typer
from . import __version__
from .backends import EngineBackend
-from .config import (
- ConfigError,
- load_or_init_config,
- parse_projects_config,
- write_config,
-)
-from .engines import get_backend, get_engine_config, list_backends
+from .config import ConfigError, load_or_init_config, write_config
+from .engines import get_backend, list_backends
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging
from .router import AutoRouter, RunnerEntry
from .runner_bridge import ExecBridgeConfig
+from .settings import (
+ TakopiSettings,
+ load_settings,
+ load_settings_if_exists,
+ require_telegram,
+ validate_settings_data,
+)
from .telegram.bridge import (
TelegramBridgeConfig,
TelegramPresenter,
@@ -29,7 +31,6 @@ from .telegram.bridge import (
run_main_loop,
)
from .telegram.client import TelegramClient
-from .telegram.config import load_telegram_config
from .telegram.onboarding import SetupResult, check_setup, interactive_setup
from .utils.git import resolve_default_base, resolve_main_worktree_root
@@ -48,25 +49,10 @@ def _version_callback(value: bool) -> None:
def load_and_validate_config(
path: str | Path | None = None,
-) -> tuple[dict, Path, str, int]:
- config, config_path = load_telegram_config(path)
- try:
- token = config["bot_token"]
- except KeyError:
- raise ConfigError(f"Missing key `bot_token` in {config_path}.") from None
- if not isinstance(token, str) or not token.strip():
- raise ConfigError(
- f"Invalid `bot_token` in {config_path}; expected a non-empty string."
- ) from None
- try:
- chat_id_value = config["chat_id"]
- except KeyError:
- raise ConfigError(f"Missing key `chat_id` in {config_path}.") from None
- if isinstance(chat_id_value, bool) or not isinstance(chat_id_value, int):
- raise ConfigError(
- f"Invalid `chat_id` in {config_path}; expected an integer."
- ) from None
- return config, config_path, token.strip(), chat_id_value
+) -> tuple[TakopiSettings, Path, str, int]:
+ settings, config_path = load_settings(path)
+ token, chat_id = require_telegram(settings, config_path)
+ return settings, config_path, token, chat_id
def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
@@ -89,13 +75,11 @@ def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
def _default_engine_for_setup(override: str | None) -> str:
if override:
return override
- try:
- config, config_path = load_telegram_config()
- except ConfigError:
- return "codex"
- value = config.get("default_engine")
- if value is None:
+ loaded = load_settings_if_exists()
+ if loaded is None:
return "codex"
+ settings, config_path = loaded
+ value = settings.default_engine
if not isinstance(value, str) or not value.strip():
raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
@@ -106,11 +90,11 @@ def _default_engine_for_setup(override: str | None) -> str:
def _resolve_default_engine(
*,
override: str | None,
- config: dict,
+ settings: TakopiSettings,
config_path: Path,
backends: list[EngineBackend],
) -> str:
- default_engine = override or config.get("default_engine") or "codex"
+ default_engine = override or settings.default_engine or "codex"
if not isinstance(default_engine, str) or not default_engine.strip():
raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
@@ -127,7 +111,7 @@ def _resolve_default_engine(
def _build_router(
*,
- config: dict,
+ settings: TakopiSettings,
config_path: Path,
backends: list[EngineBackend],
default_engine: str,
@@ -140,7 +124,7 @@ def _build_router(
issue: str | None = None
engine_cfg: dict
try:
- engine_cfg = get_engine_config(config, engine_id, config_path)
+ engine_cfg = settings.engine_config(engine_id, config_path=config_path)
except ConfigError as exc:
if engine_id == default_engine:
raise
@@ -193,7 +177,7 @@ def _parse_bridge_config(
*,
final_notify: bool,
default_engine_override: str | None,
- config: dict,
+ settings: TakopiSettings,
config_path: Path,
token: str,
chat_id: int,
@@ -201,20 +185,19 @@ def _parse_bridge_config(
startup_pwd = os.getcwd()
backends = list_backends()
- projects = parse_projects_config(
- config,
+ projects = settings.to_projects_config(
config_path=config_path,
engine_ids=[backend.id for backend in backends],
reserved=("cancel",),
)
default_engine = _resolve_default_engine(
override=default_engine_override,
- config=config,
+ settings=settings,
config_path=config_path,
backends=backends,
)
router = _build_router(
- config=config,
+ settings=settings,
config_path=config_path,
backends=backends,
default_engine=default_engine,
@@ -317,12 +300,12 @@ def _run_auto_router(
typer.echo(f"error: {first.title}", err=True)
raise typer.Exit(code=1)
try:
- config, config_path, token, chat_id = load_and_validate_config()
+ settings, config_path, token, chat_id = load_and_validate_config()
lock_handle = acquire_config_lock(config_path, token)
cfg = _parse_bridge_config(
final_notify=final_notify,
default_engine_override=default_engine_override,
- config=config,
+ settings=settings,
config_path=config_path,
token=token,
chat_id=chat_id,
@@ -391,8 +374,8 @@ def init(
alias = _prompt_alias(alias, default_alias=default_alias)
engine_ids = [backend.id for backend in list_backends()]
- projects_cfg = parse_projects_config(
- config,
+ settings = validate_settings_data(config, config_path=config_path)
+ projects_cfg = settings.to_projects_config(
config_path=config_path,
engine_ids=engine_ids,
reserved=("cancel",),
@@ -421,7 +404,7 @@ def init(
if existing is not None and existing.alias in projects:
projects.pop(existing.alias, None)
- default_engine = _default_engine_for_setup(None)
+ default_engine = settings.default_engine
worktree_base = resolve_default_base(project_path)
entry: dict[str, object] = {
diff --git a/src/takopi/config.py b/src/takopi/config.py
index 5c09b83..ee36f13 100644
--- a/src/takopi/config.py
+++ b/src/takopi/config.py
@@ -204,6 +204,8 @@ def _format_toml_value(value: Any) -> str:
return str(value)
if isinstance(value, float):
return repr(value)
+ if isinstance(value, Path):
+ return f'"{_toml_escape(str(value))}"'
if isinstance(value, str):
return f'"{_toml_escape(value)}"'
if isinstance(value, (list, tuple)):
diff --git a/src/takopi/config_store.py b/src/takopi/config_store.py
new file mode 100644
index 0000000..4c48a7f
--- /dev/null
+++ b/src/takopi/config_store.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import tomllib
+from pathlib import Path
+from typing import Any
+
+from .config import ConfigError, dump_toml
+
+
+def read_raw_toml(path: Path) -> dict[str, Any]:
+ if path.exists() and not path.is_file():
+ raise ConfigError(f"Config path {path} exists but is not a file.") from None
+ try:
+ raw = path.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ raise ConfigError(f"Missing config file {path}.") from None
+ except OSError as exc:
+ raise ConfigError(f"Failed to read config file {path}: {exc}") from exc
+ try:
+ return tomllib.loads(raw)
+ except tomllib.TOMLDecodeError as exc:
+ raise ConfigError(f"Malformed TOML in {path}: {exc}") from None
+
+
+def write_raw_toml(config: dict[str, Any], path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(dump_toml(config), encoding="utf-8")
diff --git a/src/takopi/settings.py b/src/takopi/settings.py
new file mode 100644
index 0000000..e86e667
--- /dev/null
+++ b/src/takopi/settings.py
@@ -0,0 +1,358 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Iterable
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ SecretStr,
+ ValidationError,
+ field_serializer,
+ field_validator,
+ model_validator,
+)
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from pydantic_settings.sources import TomlConfigSettingsSource
+
+from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH
+
+
+class TelegramTransportSettings(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ bot_token: SecretStr | None = None
+ chat_id: int | None = None
+
+ @field_validator("bot_token", mode="before")
+ @classmethod
+ def _validate_bot_token(cls, value: Any) -> Any:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise ValueError("bot_token must be a string")
+ return value
+
+ @field_validator("chat_id", mode="before")
+ @classmethod
+ def _validate_chat_id(cls, value: Any) -> Any:
+ if value is None:
+ return None
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ValueError("chat_id must be an integer")
+ return value
+
+ @field_serializer("bot_token")
+ def _dump_token(self, value: SecretStr | None) -> str | None:
+ return value.get_secret_value() if value else None
+
+
+class TransportsSettings(BaseModel):
+ telegram: TelegramTransportSettings = Field(
+ default_factory=TelegramTransportSettings
+ )
+
+ model_config = ConfigDict(extra="allow")
+
+
+class PluginsSettings(BaseModel):
+ enabled: list[str] = Field(default_factory=list)
+ auto_install: bool = False
+
+ model_config = ConfigDict(extra="allow")
+
+
+class ProjectSettings(BaseModel):
+ path: str
+ worktrees_dir: str = ".worktrees"
+ default_engine: str | None = None
+ worktree_base: str | None = None
+
+ model_config = ConfigDict(extra="allow")
+
+ @field_validator(
+ "path",
+ "worktrees_dir",
+ "default_engine",
+ "worktree_base",
+ mode="before",
+ )
+ @classmethod
+ def _validate_strings(cls, value: Any, info) -> Any:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise ValueError(f"{info.field_name} must be a string")
+ cleaned = value.strip()
+ if not cleaned:
+ raise ValueError(f"{info.field_name} must be a non-empty string")
+ return cleaned
+
+
+class TakopiSettings(BaseSettings):
+ model_config = SettingsConfigDict(
+ extra="allow",
+ env_prefix="TAKOPI__",
+ env_nested_delimiter="__",
+ )
+
+ default_engine: str = "codex"
+ default_project: str | None = None
+ projects: dict[str, ProjectSettings] = Field(default_factory=dict)
+
+ transport: str = "telegram"
+ transports: TransportsSettings = Field(default_factory=TransportsSettings)
+
+ plugins: PluginsSettings = Field(default_factory=PluginsSettings)
+
+ @model_validator(mode="before")
+ @classmethod
+ def _reject_legacy_telegram_keys(cls, data: Any) -> Any:
+ if isinstance(data, dict) and ("bot_token" in data or "chat_id" in data):
+ raise ValueError(
+ "Move bot_token/chat_id under [transports.telegram] "
+ 'and set transport = "telegram".'
+ )
+ return data
+
+ @field_validator("default_engine", "transport", mode="before")
+ @classmethod
+ def _validate_required_strings(cls, value: Any, info) -> Any:
+ if value is None:
+ raise ValueError(f"{info.field_name} must be a non-empty string")
+ if not isinstance(value, str):
+ raise ValueError(f"{info.field_name} must be a string")
+ cleaned = value.strip()
+ if not cleaned:
+ raise ValueError(f"{info.field_name} must be a non-empty string")
+ return cleaned
+
+ @field_validator("default_project", mode="before")
+ @classmethod
+ def _validate_default_project(cls, value: Any) -> Any:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ raise ValueError("default_project must be a string")
+ cleaned = value.strip()
+ if not cleaned:
+ raise ValueError("default_project must be a non-empty string")
+ return cleaned
+
+ @classmethod
+ def settings_customise_sources(
+ cls,
+ settings_cls,
+ init_settings,
+ env_settings,
+ dotenv_settings,
+ file_secret_settings,
+ ):
+ return (
+ init_settings,
+ env_settings,
+ dotenv_settings,
+ TomlConfigSettingsSource(settings_cls),
+ file_secret_settings,
+ )
+
+ def engine_config(self, engine_id: str, *, config_path: Path) -> dict[str, Any]:
+ extra = self.model_extra or {}
+ raw = extra.get(engine_id)
+ if raw is None:
+ return {}
+ if not isinstance(raw, dict):
+ raise ConfigError(
+ f"Invalid `{engine_id}` config in {config_path}; expected a table."
+ )
+ return raw
+
+ def to_projects_config(
+ self,
+ *,
+ config_path: Path,
+ engine_ids: Iterable[str],
+ reserved: Iterable[str] = ("cancel",),
+ ) -> ProjectsConfig:
+ default_project = self.default_project
+
+ reserved_lower = {value.lower() for value in reserved}
+ engine_map = {engine.lower(): engine for engine in engine_ids}
+ projects: dict[str, ProjectConfig] = {}
+
+ for raw_alias, entry in self.projects.items():
+ if not isinstance(raw_alias, str) or not raw_alias.strip():
+ raise ConfigError(
+ f"Invalid project alias in {config_path}; expected a non-empty string."
+ )
+ alias = raw_alias.strip()
+ alias_key = alias.lower()
+ if alias_key in engine_map or alias_key in reserved_lower:
+ raise ConfigError(
+ f"Invalid project alias {alias!r} in {config_path}; "
+ "aliases must not match engine ids or reserved commands."
+ )
+ if alias_key in projects:
+ raise ConfigError(
+ f"Duplicate project alias {alias!r} in {config_path}."
+ )
+
+ path_value = entry.path
+ if not isinstance(path_value, str) or not path_value.strip():
+ raise ConfigError(
+ f"Missing `path` for project {alias!r} in {config_path}."
+ )
+ path = _normalize_project_path(path_value.strip(), config_path=config_path)
+
+ worktrees_dir_raw = entry.worktrees_dir
+ if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip():
+ raise ConfigError(
+ f"Invalid `worktrees_dir` for project {alias!r} in {config_path}."
+ )
+ worktrees_dir = Path(worktrees_dir_raw.strip())
+
+ default_engine_raw = entry.default_engine
+ default_engine = None
+ if default_engine_raw is not None:
+ if not isinstance(default_engine_raw, str):
+ raise ConfigError(
+ f"Invalid `projects.{alias}.default_engine` in {config_path}; "
+ "expected a string."
+ )
+ default_engine = _normalize_engine_id(
+ default_engine_raw,
+ engine_ids=engine_ids,
+ config_path=config_path,
+ label=f"projects.{alias}.default_engine",
+ )
+
+ worktree_base_raw = entry.worktree_base
+ worktree_base = None
+ if worktree_base_raw is not None:
+ if (
+ not isinstance(worktree_base_raw, str)
+ or not worktree_base_raw.strip()
+ ):
+ raise ConfigError(
+ f"Invalid `projects.{alias}.worktree_base` in {config_path}; "
+ "expected a string."
+ )
+ worktree_base = worktree_base_raw.strip()
+
+ projects[alias_key] = ProjectConfig(
+ alias=alias,
+ path=path,
+ worktrees_dir=worktrees_dir,
+ default_engine=default_engine,
+ worktree_base=worktree_base,
+ )
+
+ if default_project is not None:
+ default_key = default_project.lower()
+ if default_key not in projects:
+ raise ConfigError(
+ f"Invalid `default_project` {default_project!r} in {config_path}; "
+ "no matching project alias found."
+ )
+ default_project = default_key
+
+ return ProjectsConfig(projects=projects, default_project=default_project)
+
+
+def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
+ cfg_path = _resolve_config_path(path)
+ _ensure_config_file(cfg_path)
+ return _load_settings_from_path(cfg_path), cfg_path
+
+
+def load_settings_if_exists(
+ path: str | Path | None = None,
+) -> tuple[TakopiSettings, Path] | None:
+ cfg_path = _resolve_config_path(path)
+ if cfg_path.exists():
+ if not cfg_path.is_file():
+ raise ConfigError(
+ f"Config path {cfg_path} exists but is not a file."
+ ) from None
+ return _load_settings_from_path(cfg_path), cfg_path
+ return None
+
+
+def validate_settings_data(
+ data: dict[str, Any], *, config_path: Path
+) -> TakopiSettings:
+ try:
+ return TakopiSettings.model_validate(data)
+ except ValidationError as exc:
+ raise ConfigError(f"Invalid config in {config_path}: {exc}") from exc
+
+
+def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str, int]:
+ if settings.transport != "telegram":
+ raise ConfigError(
+ f"Unsupported transport {settings.transport!r} in {config_path} "
+ "(telegram only for now)."
+ )
+ tg = settings.transports.telegram
+ if tg.bot_token is None or not tg.bot_token.get_secret_value().strip():
+ raise ConfigError(f"Missing bot token in {config_path}.")
+ if tg.chat_id is None:
+ raise ConfigError(f"Missing chat_id in {config_path}.")
+ if isinstance(tg.chat_id, bool) or not isinstance(tg.chat_id, int):
+ raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.")
+ return tg.bot_token.get_secret_value().strip(), tg.chat_id
+
+
+def _resolve_config_path(path: str | Path | None) -> Path:
+ return Path(path).expanduser() if path else HOME_CONFIG_PATH
+
+
+def _ensure_config_file(cfg_path: Path) -> None:
+ if cfg_path.exists() and not cfg_path.is_file():
+ raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
+ if not cfg_path.exists():
+ raise ConfigError(f"Missing config file {cfg_path}.") from None
+
+
+def _load_settings_from_path(cfg_path: Path) -> TakopiSettings:
+ cfg = dict(TakopiSettings.model_config)
+ cfg["toml_file"] = cfg_path
+ Bound = type(
+ "TakopiSettingsBound",
+ (TakopiSettings,),
+ {"model_config": SettingsConfigDict(**cfg)},
+ )
+ try:
+ return Bound()
+ except ValidationError as exc:
+ raise ConfigError(f"Invalid config in {cfg_path}: {exc}") from exc
+ except Exception as exc: # pragma: no cover - safety net
+ raise ConfigError(f"Failed to load config {cfg_path}: {exc}") from exc
+
+
+def _normalize_engine_id(
+ value: str,
+ *,
+ engine_ids: Iterable[str],
+ config_path: Path,
+ label: str,
+) -> str:
+ engine_map = {engine.lower(): engine for engine in engine_ids}
+ cleaned = value.strip()
+ if not cleaned:
+ raise ConfigError(f"Invalid `{label}` in {config_path}; expected a string.")
+ engine = engine_map.get(cleaned.lower())
+ if engine is None:
+ available = ", ".join(sorted(engine_map.values()))
+ raise ConfigError(
+ f"Unknown `{label}` {cleaned!r} in {config_path}. Available: {available}."
+ )
+ return engine
+
+
+def _normalize_project_path(value: str, *, config_path: Path) -> Path:
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ path = config_path.parent / path
+ return path
diff --git a/src/takopi/telegram/config.py b/src/takopi/telegram/config.py
deleted file mode 100644
index b395e0e..0000000
--- a/src/takopi/telegram/config.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from __future__ import annotations
-
-import tomllib
-from pathlib import Path
-
-from ..config import ConfigError
-
-HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml"
-
-
-def _read_config(cfg_path: Path) -> dict:
- try:
- raw = cfg_path.read_text(encoding="utf-8")
- except FileNotFoundError:
- raise ConfigError(f"Missing config file {cfg_path}.") from None
- except OSError as e:
- raise ConfigError(f"Failed to read config file {cfg_path}: {e}") from e
- try:
- return tomllib.loads(raw)
- except tomllib.TOMLDecodeError as e:
- raise ConfigError(f"Malformed TOML in {cfg_path}: {e}") from None
-
-
-def load_telegram_config(path: str | Path | None = None) -> tuple[dict, Path]:
- if path:
- cfg_path = Path(path).expanduser()
- return _read_config(cfg_path), cfg_path
- cfg_path = HOME_CONFIG_PATH
- if cfg_path.exists() and not cfg_path.is_file():
- raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
- return _read_config(cfg_path), cfg_path
diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py
index 8de6ad8..6ed7774 100644
--- a/src/takopi/telegram/onboarding.py
+++ b/src/takopi/telegram/onboarding.py
@@ -23,10 +23,11 @@ from rich.table import Table
from ..backends import EngineBackend, SetupIssue
from ..backends_helpers import install_issue
from ..config import ConfigError
+from ..config_store import read_raw_toml, write_raw_toml
from ..engines import list_backends
from ..logging import suppress_logs
+from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
from .client import TelegramClient, TelegramRetryAfter
-from .config import HOME_CONFIG_PATH, load_telegram_config
@dataclass(slots=True)
@@ -83,29 +84,23 @@ def config_issue(path: Path) -> SetupIssue:
def check_setup(backend: EngineBackend) -> SetupResult:
issues: list[SetupIssue] = []
config_path = HOME_CONFIG_PATH
- config: dict = {}
cmd = backend.cli_cmd or backend.id
backend_issues: list[SetupIssue] = []
if shutil.which(cmd) is None:
backend_issues.append(install_issue(cmd, backend.install_cmd))
try:
- config, config_path = load_telegram_config()
+ settings, config_path = load_settings()
+ try:
+ require_telegram(settings, config_path)
+ except ConfigError:
+ issues.append(config_issue(config_path))
except ConfigError:
issues.extend(backend_issues)
issues.append(config_issue(config_path))
return SetupResult(issues=issues, config_path=config_path)
- token = config.get("bot_token")
- chat_id = config.get("chat_id")
-
- missing_or_invalid_config = not (isinstance(token, str) and token.strip())
- missing_or_invalid_config |= type(chat_id) is not int
-
issues.extend(backend_issues)
- if missing_or_invalid_config:
- issues.append(config_issue(config_path))
-
return SetupResult(issues=issues, config_path=config_path)
@@ -125,11 +120,32 @@ def _render_config(token: str, chat_id: int, default_engine: str | None) -> str:
if default_engine:
lines.append(f'default_engine = "{_toml_escape(default_engine)}"')
lines.append("")
+ lines.append('transport = "telegram"')
+ lines.append("")
+ lines.append("[transports.telegram]")
lines.append(f'bot_token = "{_toml_escape(token)}"')
lines.append(f"chat_id = {chat_id}")
return "\n".join(lines) + "\n"
+def _ensure_table(
+ config: dict[str, Any],
+ key: str,
+ *,
+ config_path: Path,
+ label: str | None = None,
+) -> dict[str, Any]:
+ value = config.get(key)
+ if value is None:
+ table: dict[str, Any] = {}
+ config[key] = table
+ return table
+ if not isinstance(value, dict):
+ name = label or key
+ raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
+ return value
+
+
async def _get_bot_info(token: str) -> dict[str, Any] | None:
bot = TelegramClient(token)
try:
@@ -326,8 +342,6 @@ def interactive_setup(*, force: bool) -> bool:
console = Console()
config_path = HOME_CONFIG_PATH
- suppress_logs = _suppress_logging()
-
if config_path.exists() and not force:
console.print(
f"config already exists at {_display_path(config_path)}. "
@@ -337,13 +351,13 @@ def interactive_setup(*, force: bool) -> bool:
if config_path.exists() and force:
overwrite = _confirm(
- f"overwrite existing config at {_display_path(config_path)}?",
+ f"update existing config at {_display_path(config_path)}?",
default=False,
)
if not overwrite:
return False
- with suppress_logs:
+ with _suppress_logging():
panel = Panel(
"let's set up your telegram bot.",
title="welcome to takopi!",
@@ -421,9 +435,25 @@ def interactive_setup(*, force: bool) -> bool:
if not save:
return False
- config_path.parent.mkdir(parents=True, exist_ok=True)
- config_text = _render_config(token, chat.chat_id, default_engine)
- config_path.write_text(config_text, encoding="utf-8")
+ raw_config: dict[str, Any] = {}
+ if config_path.exists():
+ raw_config = read_raw_toml(config_path)
+ merged = dict(raw_config)
+ if default_engine is not None:
+ merged["default_engine"] = default_engine
+ merged["transport"] = "telegram"
+ transports = _ensure_table(merged, "transports", config_path=config_path)
+ telegram = _ensure_table(
+ transports,
+ "telegram",
+ config_path=config_path,
+ label="transports.telegram",
+ )
+ telegram["bot_token"] = token
+ telegram["chat_id"] = chat.chat_id
+ merged.pop("bot_token", None)
+ merged.pop("chat_id", None)
+ write_raw_toml(merged, config_path)
console.print(f" config saved to {_display_path(config_path)}")
done_panel = Panel(
diff --git a/tests/test_config_store.py b/tests/test_config_store.py
new file mode 100644
index 0000000..625a6b2
--- /dev/null
+++ b/tests/test_config_store.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from takopi.config import ConfigError
+from takopi.config_store import read_raw_toml, write_raw_toml
+
+
+def test_read_write_raw_toml_round_trip(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ payload = {
+ "default_engine": "codex",
+ "projects": {"z80": {"path": "/tmp/repo"}},
+ }
+
+ write_raw_toml(payload, config_path)
+ loaded = read_raw_toml(config_path)
+
+ assert loaded == payload
+
+
+def test_read_raw_toml_missing_file(tmp_path: Path) -> None:
+ config_path = tmp_path / "missing.toml"
+ with pytest.raises(ConfigError, match="Missing config file"):
+ read_raw_toml(config_path)
+
+
+def test_read_raw_toml_invalid_toml(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text("nope = [", encoding="utf-8")
+ with pytest.raises(ConfigError, match="Malformed TOML"):
+ read_raw_toml(config_path)
+
+
+def test_read_raw_toml_non_file(tmp_path: Path) -> None:
+ config_path = tmp_path / "config_dir"
+ config_path.mkdir()
+ with pytest.raises(ConfigError, match="exists but is not a file"):
+ read_raw_toml(config_path)
diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py
index b36c476..7813408 100644
--- a/tests/test_exec_bridge.py
+++ b/tests/test_exec_bridge.py
@@ -15,18 +15,6 @@ from tests.factories import action_completed, action_started
CODEX_ENGINE = EngineId("codex")
-def _patch_config(monkeypatch, config):
- from pathlib import Path
-
- from takopi import cli
-
- monkeypatch.setattr(
- cli,
- "load_telegram_config",
- lambda *args, **kwargs: (config, Path("takopi.toml")),
- )
-
-
class _FakeTransport:
def __init__(self) -> None:
self._next_id = 1
@@ -88,22 +76,32 @@ def _return_runner(
)
-def test_load_and_validate_config_rejects_empty_token(monkeypatch) -> None:
+def test_load_and_validate_config_rejects_empty_token(tmp_path) -> None:
from takopi import cli
- _patch_config(monkeypatch, {"bot_token": " ", "chat_id": 123})
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'transport = "telegram"\n\n[transports.telegram]\n'
+ 'bot_token = " "\nchat_id = 123\n',
+ encoding="utf-8",
+ )
- with pytest.raises(cli.ConfigError, match="bot_token"):
- cli.load_and_validate_config()
+ with pytest.raises(cli.ConfigError, match="bot token"):
+ cli.load_and_validate_config(config_path)
-def test_load_and_validate_config_rejects_string_chat_id(monkeypatch) -> None:
+def test_load_and_validate_config_rejects_string_chat_id(tmp_path) -> None:
from takopi import cli
- _patch_config(monkeypatch, {"bot_token": "token", "chat_id": "123"})
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'transport = "telegram"\n\n[transports.telegram]\n'
+ 'bot_token = "token"\nchat_id = "123"\n',
+ encoding="utf-8",
+ )
with pytest.raises(cli.ConfigError, match="chat_id"):
- cli.load_and_validate_config()
+ cli.load_and_validate_config(config_path)
def test_codex_extract_resume_finds_command() -> None:
diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py
index 0458ad9..4beb000 100644
--- a/tests/test_onboarding.py
+++ b/tests/test_onboarding.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from takopi import engines
+from takopi.settings import TakopiSettings
from takopi.telegram import onboarding
@@ -11,8 +12,16 @@ def test_check_setup_marks_missing_codex(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: None)
monkeypatch.setattr(
onboarding,
- "load_telegram_config",
- lambda: ({"bot_token": "token", "chat_id": 123}, tmp_path / "takopi.toml"),
+ "load_settings",
+ lambda: (
+ TakopiSettings.model_validate(
+ {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
+ }
+ ),
+ tmp_path / "takopi.toml",
+ ),
)
result = onboarding.check_setup(backend)
@@ -30,7 +39,7 @@ def test_check_setup_marks_missing_config(monkeypatch) -> None:
def _raise() -> None:
raise onboarding.ConfigError("Missing config file")
- monkeypatch.setattr(onboarding, "load_telegram_config", _raise)
+ monkeypatch.setattr(onboarding, "load_settings", _raise)
result = onboarding.check_setup(backend)
@@ -44,8 +53,16 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
monkeypatch.setattr(
onboarding,
- "load_telegram_config",
- lambda: ({"bot_token": "token", "chat_id": "123"}, tmp_path / "takopi.toml"),
+ "load_settings",
+ lambda: (
+ TakopiSettings.model_validate(
+ {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": None}},
+ }
+ ),
+ tmp_path / "takopi.toml",
+ ),
)
result = onboarding.check_setup(backend)
diff --git a/tests/test_onboarding_interactive.py b/tests/test_onboarding_interactive.py
index 7788500..bc883ce 100644
--- a/tests/test_onboarding_interactive.py
+++ b/tests/test_onboarding_interactive.py
@@ -23,6 +23,8 @@ def test_render_config_escapes() -> None:
"codex",
)
assert 'default_engine = "codex"' in config
+ assert 'transport = "telegram"' in config
+ assert "[transports.telegram]" in config
assert 'bot_token = "token\\"with\\\\quote"' in config
assert "chat_id = 123" in config
assert config.endswith("\n")
@@ -56,7 +58,11 @@ def _queue_values(values):
def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml"
- config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
+ config_path.write_text(
+ 'transport = "telegram"\n\n[transports.telegram]\n'
+ 'bot_token = "token"\nchat_id = 123\n',
+ encoding="utf-8",
+ )
monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path)
assert onboarding.interactive_setup(force=False) is True
@@ -95,6 +101,50 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None:
assert onboarding.interactive_setup(force=False) is True
saved = config_path.read_text(encoding="utf-8")
+ assert 'transport = "telegram"' in saved
+ assert "[transports.telegram]" in saved
assert 'bot_token = "123456789:ABCdef"' in saved
assert "chat_id = 123" in saved
assert 'default_engine = "codex"' in saved
+
+
+def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'default_project = "z80"\n\n[projects.z80]\npath = "/tmp/repo"\n',
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path)
+
+ backend = EngineBackend(id="codex", build_runner=lambda _cfg, _path: None)
+ monkeypatch.setattr(onboarding, "list_backends", lambda: [backend])
+ monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex")
+
+ monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True, True]))
+ monkeypatch.setattr(
+ onboarding.questionary, "password", _queue(["123456789:ABCdef"])
+ )
+ monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
+
+ def _fake_run(func, *args, **kwargs):
+ if func is onboarding._get_bot_info:
+ return {"username": "my_bot"}
+ if func is onboarding._wait_for_chat:
+ return onboarding.ChatInfo(
+ chat_id=123,
+ username="alice",
+ title=None,
+ first_name="Alice",
+ last_name=None,
+ chat_type="private",
+ )
+ if func is onboarding._send_confirmation:
+ return True
+ raise AssertionError(f"unexpected anyio.run target: {func}")
+
+ monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
+
+ assert onboarding.interactive_setup(force=True) is True
+ saved = config_path.read_text(encoding="utf-8")
+ assert "[projects.z80]" in saved
+ assert 'path = "/tmp/repo"' in saved
diff --git a/tests/test_projects_config.py b/tests/test_projects_config.py
index fb78225..bb20121 100644
--- a/tests/test_projects_config.py
+++ b/tests/test_projects_config.py
@@ -4,14 +4,15 @@ import pytest
from typer.testing import CliRunner
from takopi import cli
-from takopi.config import ConfigError, parse_projects_config
+from takopi.config import ConfigError
+from takopi.settings import TakopiSettings
def test_parse_projects_rejects_engine_alias() -> None:
config = {"projects": {"codex": {"path": "/tmp/repo"}}}
with pytest.raises(ConfigError, match="aliases must not match engine ids"):
- parse_projects_config(
- config,
+ settings = TakopiSettings.model_validate(config)
+ settings.to_projects_config(
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
@@ -21,8 +22,8 @@ def test_parse_projects_rejects_engine_alias() -> None:
def test_parse_projects_default_project_must_exist() -> None:
config = {"default_project": "z80", "projects": {}}
with pytest.raises(ConfigError, match="default_project"):
- parse_projects_config(
- config,
+ settings = TakopiSettings.model_validate(config)
+ settings.to_projects_config(
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
@@ -47,3 +48,25 @@ def test_init_writes_project(monkeypatch, tmp_path) -> None:
assert 'worktrees_dir = ".worktrees"' in saved
assert 'default_engine = "codex"' in saved
assert 'worktree_base = "main"' in saved
+
+
+def test_projects_default_engine_unknown() -> None:
+ config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}}
+ settings = TakopiSettings.model_validate(config)
+ with pytest.raises(ConfigError, match="projects.z80.default_engine"):
+ settings.to_projects_config(
+ config_path=Path("takopi.toml"),
+ engine_ids=["codex"],
+ reserved=("cancel",),
+ )
+
+
+def test_projects_relative_path_resolves(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}})
+ projects = settings.to_projects_config(
+ config_path=config_path,
+ engine_ids=["codex"],
+ reserved=("cancel",),
+ )
+ assert projects.projects["z80"].path == config_path.parent / "repo"
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..8648399
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from takopi.config import ConfigError
+from takopi.settings import (
+ TakopiSettings,
+ load_settings,
+ load_settings_if_exists,
+ require_telegram,
+ validate_settings_data,
+)
+
+
+def test_load_settings_from_toml(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'transport = "telegram"\n\n'
+ "[transports.telegram]\n"
+ 'bot_token = "token"\n'
+ "chat_id = 123\n\n"
+ "[codex]\n"
+ 'model = "gpt-4"\n',
+ encoding="utf-8",
+ )
+
+ settings, loaded_path = load_settings(config_path)
+
+ assert loaded_path == config_path
+ assert settings.transport == "telegram"
+ assert settings.transports.telegram.chat_id == 123
+ assert settings.engine_config("codex", config_path=config_path)["model"] == "gpt-4"
+
+ token, chat_id = require_telegram(settings, config_path)
+ assert token == "token"
+ assert chat_id == 123
+
+ dumped = settings.model_dump()
+ assert dumped["transports"]["telegram"]["bot_token"] == "token"
+
+
+def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'default_engine = "codex"\n'
+ 'transport = "telegram"\n\n'
+ "[transports.telegram]\n"
+ 'bot_token = "token"\n'
+ "chat_id = 123\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("TAKOPI__DEFAULT_ENGINE", "claude")
+
+ settings, _ = load_settings(config_path)
+
+ assert settings.default_engine == "claude"
+
+
+def test_legacy_keys_rejected(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
+
+ with pytest.raises(ConfigError, match="transports\\.telegram"):
+ load_settings(config_path)
+
+
+def test_validate_settings_data_rejects_invalid_bot_token_type(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ data = {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": 123, "chat_id": 123}},
+ }
+
+ with pytest.raises(ConfigError, match="bot_token"):
+ validate_settings_data(data, config_path=config_path)
+
+
+def test_validate_settings_data_rejects_empty_default_engine(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ data = {
+ "default_engine": " ",
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
+ }
+
+ with pytest.raises(ConfigError, match="default_engine"):
+ validate_settings_data(data, config_path=config_path)
+
+
+def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ data = {"default_project": " "}
+
+ with pytest.raises(ConfigError, match="default_project"):
+ validate_settings_data(data, config_path=config_path)
+
+
+def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ data = {"projects": {"z80": {"path": " "}}}
+
+ with pytest.raises(ConfigError, match="path"):
+ validate_settings_data(data, config_path=config_path)
+
+
+def test_engine_config_none_and_invalid(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ settings = TakopiSettings.model_validate(
+ {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
+ "codex": None,
+ }
+ )
+ assert settings.engine_config("codex", config_path=config_path) == {}
+
+ settings = TakopiSettings.model_validate(
+ {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
+ "codex": "nope",
+ }
+ )
+ with pytest.raises(ConfigError, match="codex"):
+ settings.engine_config("codex", config_path=config_path)
+
+
+def test_bot_token_none_allowed() -> None:
+ settings = TakopiSettings.model_validate(
+ {
+ "transport": "telegram",
+ "transports": {"telegram": {"bot_token": None, "chat_id": 123}},
+ }
+ )
+ assert settings.transports.telegram.bot_token is None
+
+
+def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ settings = TakopiSettings.model_validate(
+ {
+ "transport": "discord",
+ "transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
+ }
+ )
+ with pytest.raises(ConfigError, match="Unsupported transport"):
+ require_telegram(settings, config_path)
+
+
+def test_load_settings_if_exists_missing(tmp_path: Path) -> None:
+ config_path = tmp_path / "missing.toml"
+ assert load_settings_if_exists(config_path) is None
+
+
+def test_load_settings_missing_file(tmp_path: Path) -> None:
+ config_path = tmp_path / "missing.toml"
+ with pytest.raises(ConfigError, match="Missing config file"):
+ load_settings(config_path)
+
+
+def test_load_settings_if_exists_loads(tmp_path: Path) -> None:
+ config_path = tmp_path / "takopi.toml"
+ config_path.write_text(
+ 'transport = "telegram"\n\n[transports.telegram]\n'
+ 'bot_token = "token"\nchat_id = 123\n',
+ encoding="utf-8",
+ )
+
+ loaded = load_settings_if_exists(config_path)
+ assert loaded is not None
+ settings, loaded_path = loaded
+ assert loaded_path == config_path
+
+
+def test_load_settings_if_exists_rejects_non_file(tmp_path: Path) -> None:
+ config_path = tmp_path / "config_dir"
+ config_path.mkdir()
+ with pytest.raises(ConfigError, match="exists but is not a file"):
+ load_settings_if_exists(config_path)
+
+
+def test_load_settings_rejects_non_file(tmp_path: Path) -> None:
+ config_path = tmp_path / "config_dir"
+ config_path.mkdir()
+ with pytest.raises(ConfigError, match="exists but is not a file"):
+ load_settings(config_path)
diff --git a/uv.lock b/uv.lock
index 9a2e46b..7c20299 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,6 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.14"
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
[[package]]
name = "anyio"
version = "4.12.0"
@@ -266,6 +275,74 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"
@@ -318,6 +395,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+]
+
[[package]]
name = "questionary"
version = "2.1.1"
@@ -418,6 +504,8 @@ dependencies = [
{ name = "httpx" },
{ name = "markdown-it-py" },
{ name = "msgspec" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
{ name = "questionary" },
{ name = "rich" },
{ name = "structlog" },
@@ -440,6 +528,8 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" },
+ { name = "pydantic", specifier = ">=2.12.5" },
+ { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "questionary", specifier = ">=2.1.1" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "structlog", specifier = ">=25.5.0" },
@@ -505,6 +595,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
[[package]]
name = "wcwidth"
version = "0.2.14"