yee88 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
takopi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.21.4"
|
takopi/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
|
+
]
|
takopi/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
|
+
)
|
takopi/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://takopi.dev/",
|
|
198
|
+
)
|
|
199
|
+
config_app = typer.Typer(help="Read and modify takopi 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()
|
takopi/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 takopi config at {display}", err=True)
|
|
43
|
+
else:
|
|
44
|
+
typer.echo(f"error: missing takopi 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("takopi.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)
|