yee88 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
yee88/api.py ADDED
@@ -0,0 +1,116 @@
1
+ """Stable public API for Takopi plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .backends import EngineBackend, EngineConfig, SetupIssue
6
+ from .commands import (
7
+ CommandBackend,
8
+ CommandContext,
9
+ CommandExecutor,
10
+ CommandResult,
11
+ RunMode,
12
+ RunRequest,
13
+ RunResult,
14
+ )
15
+ from .config import ConfigError
16
+ from .context import RunContext
17
+ from .directives import DirectiveError
18
+ from .events import EventFactory
19
+ from .model import (
20
+ Action,
21
+ ActionEvent,
22
+ CompletedEvent,
23
+ EngineId,
24
+ ResumeToken,
25
+ StartedEvent,
26
+ )
27
+ from .presenter import Presenter
28
+ from .progress import ActionState, ProgressState, ProgressTracker
29
+ from .router import RunnerUnavailableError
30
+ from .runner import BaseRunner, JsonlSubprocessRunner, Runner
31
+ from .runner_bridge import (
32
+ ExecBridgeConfig,
33
+ IncomingMessage,
34
+ RunningTask,
35
+ RunningTasks,
36
+ handle_message,
37
+ )
38
+ from .transport import MessageRef, RenderedMessage, SendOptions, Transport
39
+ from .transport_runtime import ResolvedMessage, ResolvedRunner, TransportRuntime
40
+ from .transports import SetupResult, TransportBackend
41
+
42
+ from .config import HOME_CONFIG_PATH, read_config, write_config
43
+ from .ids import RESERVED_COMMAND_IDS
44
+ from .logging import bind_run_context, clear_context, get_logger, suppress_logs
45
+ from .utils.paths import reset_run_base_dir, set_run_base_dir
46
+ from .scheduler import ThreadJob, ThreadScheduler
47
+ from .commands import get_command, list_command_ids
48
+ from .engines import list_backends
49
+ from .settings import load_settings
50
+ from .backends_helpers import install_issue
51
+
52
+ TAKOPI_PLUGIN_API_VERSION = 1
53
+
54
+ __all__ = [
55
+ # Core types
56
+ "Action",
57
+ "ActionEvent",
58
+ "BaseRunner",
59
+ "CompletedEvent",
60
+ "ConfigError",
61
+ "CommandBackend",
62
+ "CommandContext",
63
+ "CommandExecutor",
64
+ "CommandResult",
65
+ "EngineBackend",
66
+ "EngineConfig",
67
+ "EngineId",
68
+ "ExecBridgeConfig",
69
+ "EventFactory",
70
+ "IncomingMessage",
71
+ "JsonlSubprocessRunner",
72
+ "MessageRef",
73
+ "DirectiveError",
74
+ "Presenter",
75
+ "ProgressState",
76
+ "ProgressTracker",
77
+ "ActionState",
78
+ "RenderedMessage",
79
+ "ResumeToken",
80
+ "RunMode",
81
+ "RunRequest",
82
+ "RunResult",
83
+ "ResolvedMessage",
84
+ "ResolvedRunner",
85
+ "RunContext",
86
+ "Runner",
87
+ "RunnerUnavailableError",
88
+ "RunningTask",
89
+ "RunningTasks",
90
+ "SendOptions",
91
+ "SetupIssue",
92
+ "SetupResult",
93
+ "StartedEvent",
94
+ "TAKOPI_PLUGIN_API_VERSION",
95
+ "Transport",
96
+ "TransportBackend",
97
+ "TransportRuntime",
98
+ "handle_message",
99
+ "HOME_CONFIG_PATH",
100
+ "RESERVED_COMMAND_IDS",
101
+ "read_config",
102
+ "write_config",
103
+ "get_logger",
104
+ "bind_run_context",
105
+ "clear_context",
106
+ "suppress_logs",
107
+ "set_run_base_dir",
108
+ "reset_run_base_dir",
109
+ "ThreadJob",
110
+ "ThreadScheduler",
111
+ "get_command",
112
+ "list_command_ids",
113
+ "list_backends",
114
+ "load_settings",
115
+ "install_issue",
116
+ ]
yee88/backends.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from .runner import Runner
10
+
11
+ EngineConfig = dict[str, Any]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SetupIssue:
16
+ title: str
17
+ lines: tuple[str, ...]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class EngineBackend:
22
+ id: str
23
+ build_runner: Callable[[EngineConfig, Path], Runner]
24
+ cli_cmd: str | None = None
25
+ install_cmd: str | None = None
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from .backends import SetupIssue
4
+
5
+
6
+ def install_issue(cmd: str, install_cmd: str | None) -> SetupIssue:
7
+ if install_cmd:
8
+ lines = (f" [dim]$[/] {install_cmd}",)
9
+ else:
10
+ lines = (" [dim]See engine setup docs for install instructions.[/]",)
11
+ return SetupIssue(
12
+ f"install {cmd}",
13
+ lines,
14
+ )
yee88/cli/__init__.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: F401
4
+
5
+ from collections.abc import Callable
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from .. import __version__
12
+ from ..config import (
13
+ ConfigError,
14
+ HOME_CONFIG_PATH,
15
+ load_or_init_config,
16
+ write_config,
17
+ )
18
+ from ..config_migrations import migrate_config
19
+ from ..commands import get_command
20
+ from ..engines import get_backend, list_backend_ids
21
+ from ..ids import RESERVED_CHAT_COMMANDS, RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
22
+ from ..lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
23
+ from ..logging import setup_logging
24
+ from ..runtime_loader import build_runtime_spec, resolve_plugins_allowlist
25
+ from ..settings import (
26
+ TakopiSettings,
27
+ load_settings,
28
+ load_settings_if_exists,
29
+ validate_settings_data,
30
+ )
31
+ from ..plugins import (
32
+ COMMAND_GROUP,
33
+ ENGINE_GROUP,
34
+ TRANSPORT_GROUP,
35
+ entrypoint_distribution_name,
36
+ get_load_errors,
37
+ is_entrypoint_allowed,
38
+ list_entrypoints,
39
+ normalize_allowlist,
40
+ )
41
+ from ..transports import get_transport
42
+ from ..utils.git import resolve_default_base, resolve_main_worktree_root
43
+ from ..telegram import onboarding
44
+ from ..telegram.client import TelegramClient
45
+ from ..telegram.topics import _validate_topics_setup_for
46
+ from .doctor import (
47
+ DoctorCheck,
48
+ DoctorStatus,
49
+ _doctor_file_checks,
50
+ _doctor_telegram_checks,
51
+ _doctor_voice_checks,
52
+ run_doctor,
53
+ )
54
+ from .init import (
55
+ _default_alias_from_path,
56
+ _ensure_projects_table,
57
+ _prompt_alias,
58
+ run_init,
59
+ )
60
+ from .onboarding_cmd import chat_id, onboarding_paths
61
+ from .topic import run_topic
62
+ from .plugins import plugins_cmd
63
+ from .run import (
64
+ _default_engine_for_setup,
65
+ _print_version_and_exit,
66
+ _resolve_setup_engine,
67
+ _resolve_transport_id,
68
+ _run_auto_router,
69
+ _setup_needs_config,
70
+ _should_run_interactive,
71
+ _version_callback,
72
+ acquire_config_lock,
73
+ app_main,
74
+ make_engine_cmd,
75
+ )
76
+ from .config import (
77
+ _CONFIG_PATH_OPTION,
78
+ _config_path_display,
79
+ _exit_config_error,
80
+ _fail_missing_config,
81
+ _flatten_config,
82
+ _load_config_or_exit,
83
+ _normalized_value_from_settings,
84
+ _parse_key_path,
85
+ _parse_value,
86
+ _resolve_config_path_override,
87
+ _toml_literal,
88
+ config_get,
89
+ config_list,
90
+ config_path_cmd,
91
+ config_set,
92
+ config_unset,
93
+ )
94
+
95
+
96
+ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
97
+ try:
98
+ loaded = load_settings_if_exists()
99
+ except ConfigError:
100
+ return None, None
101
+ if loaded is None:
102
+ return None, None
103
+ return loaded
104
+
105
+
106
+ def init(
107
+ alias: str | None = typer.Argument(
108
+ None, help="Project alias (used as /alias in messages)."
109
+ ),
110
+ default: bool = typer.Option(
111
+ False,
112
+ "--default",
113
+ help="Set this project as the default_project.",
114
+ ),
115
+ ) -> None:
116
+ """Register the current repo as a Takopi project."""
117
+ run_init(
118
+ alias=alias,
119
+ default=default,
120
+ load_or_init_config_fn=load_or_init_config,
121
+ resolve_main_worktree_root_fn=resolve_main_worktree_root,
122
+ resolve_default_base_fn=resolve_default_base,
123
+ list_backend_ids_fn=list_backend_ids,
124
+ resolve_plugins_allowlist_fn=resolve_plugins_allowlist,
125
+ )
126
+
127
+
128
+ def topic_init(
129
+ project: str | None = typer.Argument(
130
+ None, help="Project alias (defaults to current directory name)."
131
+ ),
132
+ branch: str | None = typer.Option(
133
+ None, "--branch", "-b", help="Branch name (defaults to current git branch)."
134
+ ),
135
+ ) -> None:
136
+ """Create a Telegram topic bound to a project/branch."""
137
+ run_topic(
138
+ project=project,
139
+ branch=branch,
140
+ delete=False,
141
+ config_path=None,
142
+ )
143
+
144
+
145
+ def topic_delete(
146
+ project: str | None = typer.Argument(
147
+ None, help="Project alias (defaults to current directory name)."
148
+ ),
149
+ branch: str | None = typer.Option(
150
+ None, "--branch", "-b", help="Branch name (defaults to current git branch)."
151
+ ),
152
+ ) -> None:
153
+ """Delete a Telegram topic binding."""
154
+ run_topic(
155
+ project=project,
156
+ branch=branch,
157
+ delete=True,
158
+ config_path=None,
159
+ )
160
+
161
+
162
+ def doctor() -> None:
163
+ """Run configuration checks for the active transport."""
164
+ setup_logging(debug=False, cache_logger_on_first_use=False)
165
+ run_doctor(
166
+ load_settings_fn=load_settings,
167
+ telegram_checks=_doctor_telegram_checks,
168
+ file_checks=_doctor_file_checks,
169
+ voice_checks=_doctor_voice_checks,
170
+ )
171
+
172
+
173
+ def _engine_ids_for_cli() -> list[str]:
174
+ allowlist: list[str] | None = None
175
+ try:
176
+ config, _ = load_or_init_config()
177
+ except ConfigError:
178
+ return list_backend_ids()
179
+ raw_plugins = config.get("plugins")
180
+ if isinstance(raw_plugins, dict):
181
+ enabled = raw_plugins.get("enabled")
182
+ if isinstance(enabled, list):
183
+ allowlist = [
184
+ value.strip()
185
+ for value in enabled
186
+ if isinstance(value, str) and value.strip()
187
+ ]
188
+ if not allowlist:
189
+ allowlist = None
190
+ return list_backend_ids(allowlist=allowlist)
191
+
192
+
193
+ def create_app() -> typer.Typer:
194
+ app = typer.Typer(
195
+ add_completion=False,
196
+ invoke_without_command=True,
197
+ help="Telegram bridge for coding agents. Docs: https://yee88.dev/",
198
+ )
199
+ config_app = typer.Typer(help="Read and modify yee88 config.")
200
+ config_app.command(name="path")(config_path_cmd)
201
+ config_app.command(name="list")(config_list)
202
+ config_app.command(name="get")(config_get)
203
+ config_app.command(name="set")(config_set)
204
+ config_app.command(name="unset")(config_unset)
205
+ topic_app = typer.Typer(help="Manage Telegram topics.")
206
+ topic_app.command(name="init")(topic_init)
207
+ topic_app.command(name="delete")(topic_delete)
208
+ app.command(name="init")(init)
209
+ app.add_typer(topic_app, name="topic")
210
+ app.command(name="chat-id")(chat_id)
211
+ app.command(name="doctor")(doctor)
212
+ app.command(name="onboarding-paths")(onboarding_paths)
213
+ app.command(name="plugins")(plugins_cmd)
214
+ app.add_typer(config_app, name="config")
215
+ app.callback()(app_main)
216
+ for engine_id in _engine_ids_for_cli():
217
+ help_text = f"Run with the {engine_id} engine."
218
+ app.command(name=engine_id, help=help_text)(make_engine_cmd(engine_id))
219
+ return app
220
+
221
+
222
+ def main() -> None:
223
+ app = create_app()
224
+ app()
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
yee88/cli/config.py ADDED
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from pydantic import BaseModel
11
+
12
+ from ..config import (
13
+ ConfigError,
14
+ HOME_CONFIG_PATH,
15
+ dump_toml,
16
+ read_config,
17
+ write_config,
18
+ )
19
+ from ..config_migrations import migrate_config
20
+ from ..settings import TakopiSettings, validate_settings_data
21
+
22
+ _KEY_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
23
+ _MISSING = object()
24
+ _CONFIG_PATH_OPTION = typer.Option(
25
+ None,
26
+ "--config-path",
27
+ help="Override the default config path.",
28
+ )
29
+
30
+
31
+ def _config_path_display(path: Path) -> str:
32
+ home = Path.home()
33
+ try:
34
+ return f"~/{path.relative_to(home)}"
35
+ except ValueError:
36
+ return str(path)
37
+
38
+
39
+ def _fail_missing_config(path: Path) -> None:
40
+ display = _config_path_display(path)
41
+ if path.exists():
42
+ typer.echo(f"error: invalid yee88 config at {display}", err=True)
43
+ else:
44
+ typer.echo(f"error: missing yee88 config at {display}", err=True)
45
+
46
+
47
+ def _resolve_config_path_override(value: Path | None) -> Path:
48
+ if value is None:
49
+ return _resolve_home_config_path()
50
+ return value.expanduser()
51
+
52
+
53
+ def _resolve_home_config_path() -> Path:
54
+ cli_module = sys.modules.get("yee88.cli")
55
+ if cli_module is not None:
56
+ override = getattr(cli_module, "HOME_CONFIG_PATH", None)
57
+ if override is not None:
58
+ return Path(override)
59
+ return HOME_CONFIG_PATH
60
+
61
+
62
+ def _exit_config_error(exc: ConfigError, *, code: int = 2) -> None:
63
+ typer.echo(f"error: {exc}", err=True)
64
+ raise typer.Exit(code=code) from exc
65
+
66
+
67
+ def _parse_key_path(raw: str) -> list[str]:
68
+ value = raw.strip()
69
+ if not value:
70
+ raise ConfigError("Invalid key path; expected a non-empty value.")
71
+ segments = value.split(".")
72
+ for segment in segments:
73
+ if not segment:
74
+ raise ConfigError(f"Invalid key path {raw!r}; empty segment.")
75
+ if not _KEY_SEGMENT_RE.fullmatch(segment):
76
+ raise ConfigError(
77
+ f"Invalid key segment {segment!r} in {raw!r}; "
78
+ "use only letters, numbers, '_' or '-'."
79
+ )
80
+ return segments
81
+
82
+
83
+ def _parse_value(raw: str) -> Any:
84
+ value = raw.strip()
85
+ if not value:
86
+ return ""
87
+ try:
88
+ return tomllib.loads(f"__v__ = {value}")["__v__"]
89
+ except tomllib.TOMLDecodeError:
90
+ return value
91
+
92
+
93
+ def _toml_literal(value: Any) -> str:
94
+ dumped = dump_toml({"__v__": value})
95
+ prefix = "__v__ = "
96
+ if dumped.startswith(prefix):
97
+ return dumped[len(prefix) :].rstrip("\n")
98
+ raise ConfigError("Unsupported config value; unable to render TOML literal.")
99
+
100
+
101
+ def _normalized_value_from_settings(
102
+ settings: TakopiSettings, segments: list[str]
103
+ ) -> Any:
104
+ node: Any = settings
105
+ for segment in segments:
106
+ if isinstance(node, BaseModel):
107
+ if segment in node.__class__.model_fields:
108
+ node = getattr(node, segment)
109
+ else:
110
+ extra = node.model_extra or {}
111
+ node = extra.get(segment, _MISSING)
112
+ elif isinstance(node, dict):
113
+ node = node.get(segment, _MISSING)
114
+ else:
115
+ return _MISSING
116
+ if node is _MISSING:
117
+ return _MISSING
118
+ if isinstance(node, BaseModel):
119
+ return node.model_dump(exclude_unset=True)
120
+ return node
121
+
122
+
123
+ def _flatten_config(config: dict[str, Any]) -> list[tuple[str, Any]]:
124
+ items: list[tuple[str, Any]] = []
125
+
126
+ def _walk(node: Any, prefix: str) -> None:
127
+ if isinstance(node, dict):
128
+ for key in sorted(node):
129
+ value = node[key]
130
+ path = f"{prefix}.{key}" if prefix else key
131
+ if isinstance(value, dict):
132
+ _walk(value, path)
133
+ else:
134
+ items.append((path, value))
135
+ elif prefix:
136
+ items.append((prefix, node))
137
+
138
+ _walk(config, "")
139
+ return items
140
+
141
+
142
+ def _load_config_or_exit(path: Path, *, missing_code: int) -> dict[str, Any]:
143
+ if not path.exists():
144
+ _fail_missing_config(path)
145
+ raise typer.Exit(code=missing_code)
146
+ try:
147
+ return read_config(path)
148
+ except ConfigError as exc:
149
+ _exit_config_error(exc)
150
+ return {}
151
+
152
+
153
+ def config_path_cmd(
154
+ config_path: Path | None = _CONFIG_PATH_OPTION,
155
+ ) -> None:
156
+ """Print the resolved config path."""
157
+ path = _resolve_config_path_override(config_path)
158
+ typer.echo(_config_path_display(path))
159
+
160
+
161
+ def config_list(
162
+ config_path: Path | None = _CONFIG_PATH_OPTION,
163
+ ) -> None:
164
+ """List config keys as flattened dot-paths."""
165
+ path = _resolve_config_path_override(config_path)
166
+ config = _load_config_or_exit(path, missing_code=1)
167
+ try:
168
+ for key, value in _flatten_config(config):
169
+ literal = _toml_literal(value)
170
+ typer.echo(f"{key} = {literal}")
171
+ except ConfigError as exc:
172
+ _exit_config_error(exc)
173
+
174
+
175
+ def config_get(
176
+ key: str = typer.Argument(..., help="Dot-path key to fetch."),
177
+ config_path: Path | None = _CONFIG_PATH_OPTION,
178
+ ) -> None:
179
+ """Fetch a single config key."""
180
+ path = _resolve_config_path_override(config_path)
181
+ config = _load_config_or_exit(path, missing_code=2)
182
+ try:
183
+ segments = _parse_key_path(key)
184
+ except ConfigError as exc:
185
+ _exit_config_error(exc)
186
+
187
+ node: Any = config
188
+ for index, segment in enumerate(segments):
189
+ if not isinstance(node, dict):
190
+ prefix = ".".join(segments[:index])
191
+ _exit_config_error(
192
+ ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
193
+ )
194
+ if segment not in node:
195
+ raise typer.Exit(code=1)
196
+ node = node[segment]
197
+
198
+ if isinstance(node, dict):
199
+ typer.echo(
200
+ f"error: {'.'.join(segments)!r} is a table; pick a leaf node.",
201
+ err=True,
202
+ )
203
+ raise typer.Exit(code=2)
204
+
205
+ try:
206
+ typer.echo(_toml_literal(node))
207
+ except ConfigError as exc:
208
+ _exit_config_error(exc)
209
+
210
+
211
+ def config_set(
212
+ key: str = typer.Argument(..., help="Dot-path key to set."),
213
+ value: str = typer.Argument(..., help="Value to assign (auto-parsed)."),
214
+ config_path: Path | None = _CONFIG_PATH_OPTION,
215
+ ) -> None:
216
+ """Set a config value."""
217
+ path = _resolve_config_path_override(config_path)
218
+ config = _load_config_or_exit(path, missing_code=2)
219
+ try:
220
+ segments = _parse_key_path(key)
221
+ except ConfigError as exc:
222
+ _exit_config_error(exc)
223
+
224
+ try:
225
+ migrate_config(config, config_path=path)
226
+ except ConfigError as exc:
227
+ _exit_config_error(exc)
228
+
229
+ parsed = _parse_value(value)
230
+ node: Any = config
231
+ for index, segment in enumerate(segments[:-1]):
232
+ next_node = node.get(segment)
233
+ if next_node is None:
234
+ created: dict[str, Any] = {}
235
+ node[segment] = created
236
+ node = created
237
+ continue
238
+ if not isinstance(next_node, dict):
239
+ prefix = ".".join(segments[: index + 1])
240
+ _exit_config_error(
241
+ ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
242
+ )
243
+ node = next_node
244
+ node[segments[-1]] = parsed
245
+
246
+ try:
247
+ settings = validate_settings_data(config, config_path=path)
248
+ except ConfigError as exc:
249
+ _exit_config_error(exc)
250
+
251
+ normalized = _normalized_value_from_settings(settings, segments)
252
+ if normalized is not _MISSING:
253
+ node[segments[-1]] = normalized
254
+ parsed = normalized
255
+
256
+ try:
257
+ write_config(config, path)
258
+ except ConfigError as exc:
259
+ _exit_config_error(exc)
260
+
261
+ rendered = _toml_literal(parsed)
262
+ typer.echo(f"updated {'.'.join(segments)} = {rendered}")
263
+
264
+
265
+ def config_unset(
266
+ key: str = typer.Argument(..., help="Dot-path key to remove."),
267
+ config_path: Path | None = _CONFIG_PATH_OPTION,
268
+ ) -> None:
269
+ """Remove a config key."""
270
+ path = _resolve_config_path_override(config_path)
271
+ config = _load_config_or_exit(path, missing_code=2)
272
+ try:
273
+ segments = _parse_key_path(key)
274
+ except ConfigError as exc:
275
+ _exit_config_error(exc)
276
+
277
+ try:
278
+ migrate_config(config, config_path=path)
279
+ except ConfigError as exc:
280
+ _exit_config_error(exc)
281
+
282
+ node: Any = config
283
+ stack: list[tuple[dict[str, Any], str]] = []
284
+ for index, segment in enumerate(segments[:-1]):
285
+ if not isinstance(node, dict):
286
+ prefix = ".".join(segments[:index])
287
+ _exit_config_error(
288
+ ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
289
+ )
290
+ next_node = node.get(segment)
291
+ if next_node is None:
292
+ raise typer.Exit(code=1)
293
+ if not isinstance(next_node, dict):
294
+ prefix = ".".join(segments[: index + 1])
295
+ _exit_config_error(
296
+ ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
297
+ )
298
+ stack.append((node, segment))
299
+ node = next_node
300
+
301
+ if not isinstance(node, dict):
302
+ prefix = ".".join(segments[:-1])
303
+ _exit_config_error(
304
+ ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
305
+ )
306
+ leaf = segments[-1]
307
+ if leaf not in node:
308
+ raise typer.Exit(code=1)
309
+ node.pop(leaf, None)
310
+
311
+ while stack and not node:
312
+ parent, key_name = stack.pop()
313
+ parent.pop(key_name, None)
314
+ node = parent
315
+
316
+ try:
317
+ validate_settings_data(config, config_path=path)
318
+ write_config(config, path)
319
+ except ConfigError as exc:
320
+ _exit_config_error(exc)