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.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..chat_prefs import ChatPrefsStore
6
+ from ..files import split_command_args
7
+ from ..topic_state import TopicStateStore
8
+ from ..topics import _topic_key
9
+ from ..trigger_mode import resolve_trigger_mode
10
+ from ..types import TelegramIncomingMessage
11
+ from .overrides import check_admin_or_private
12
+ from .plan import ActionPlan
13
+ from .reply import make_reply
14
+
15
+ if TYPE_CHECKING:
16
+ from ..bridge import TelegramBridgeConfig
17
+
18
+ TRIGGER_USAGE = (
19
+ "usage: `/trigger`, `/trigger all`, `/trigger mentions`, or `/trigger clear`"
20
+ )
21
+
22
+
23
+ async def _handle_trigger_command(
24
+ cfg: TelegramBridgeConfig,
25
+ msg: TelegramIncomingMessage,
26
+ args_text: str,
27
+ _ambient_context,
28
+ topic_store: TopicStateStore | None,
29
+ chat_prefs: ChatPrefsStore | None,
30
+ *,
31
+ resolved_scope: str | None = None,
32
+ scope_chat_ids: frozenset[int] | None = None,
33
+ ) -> None:
34
+ reply = make_reply(cfg, msg)
35
+ plan = await _plan_trigger_command(
36
+ cfg,
37
+ msg,
38
+ args_text=args_text,
39
+ topic_store=topic_store,
40
+ chat_prefs=chat_prefs,
41
+ scope_chat_ids=scope_chat_ids,
42
+ )
43
+ await plan.execute(reply)
44
+
45
+
46
+ async def _plan_trigger_command(
47
+ cfg: TelegramBridgeConfig,
48
+ msg: TelegramIncomingMessage,
49
+ *,
50
+ args_text: str,
51
+ topic_store: TopicStateStore | None,
52
+ chat_prefs: ChatPrefsStore | None,
53
+ scope_chat_ids: frozenset[int] | None,
54
+ ) -> ActionPlan:
55
+ tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
56
+ tokens = split_command_args(args_text)
57
+ action = tokens[0].lower() if tokens else "show"
58
+
59
+ if action in {"show", ""}:
60
+ resolved = await resolve_trigger_mode(
61
+ chat_id=msg.chat_id,
62
+ thread_id=msg.thread_id,
63
+ chat_prefs=chat_prefs,
64
+ topic_store=topic_store,
65
+ )
66
+ topic_mode = None
67
+ if tkey is not None and topic_store is not None:
68
+ topic_mode = await topic_store.get_trigger_mode(tkey[0], tkey[1])
69
+ chat_mode = None
70
+ if chat_prefs is not None:
71
+ chat_mode = await chat_prefs.get_trigger_mode(msg.chat_id)
72
+ if topic_mode is not None:
73
+ source = "topic override"
74
+ elif chat_mode is not None:
75
+ source = "chat default"
76
+ else:
77
+ source = "default"
78
+ trigger_line = f"trigger: {resolved} ({source})"
79
+ topic_label = topic_mode or "none"
80
+ if tkey is None:
81
+ topic_label = "none"
82
+ chat_label = "unavailable" if chat_prefs is None else chat_mode or "none"
83
+ defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}"
84
+ available_line = "available: all, mentions"
85
+ return ActionPlan(
86
+ reply_text="\n\n".join([trigger_line, defaults_line, available_line])
87
+ )
88
+
89
+ if action in {"all", "mentions"}:
90
+ decision = await check_admin_or_private(
91
+ cfg,
92
+ msg,
93
+ missing_sender="cannot verify sender for trigger settings.",
94
+ failed_member="failed to verify trigger permissions.",
95
+ denied="changing trigger mode is restricted to group admins.",
96
+ )
97
+ if not decision.allowed:
98
+ return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE)
99
+ if tkey is not None:
100
+ if topic_store is None:
101
+ return ActionPlan(reply_text="topic trigger settings are unavailable.")
102
+ return ActionPlan(
103
+ reply_text=f"topic trigger mode set to `{action}`",
104
+ actions=(
105
+ lambda: topic_store.set_trigger_mode(tkey[0], tkey[1], action),
106
+ ),
107
+ )
108
+ if chat_prefs is None:
109
+ return ActionPlan(
110
+ reply_text="chat trigger settings are unavailable (no config path)."
111
+ )
112
+ return ActionPlan(
113
+ reply_text=f"chat trigger mode set to `{action}`",
114
+ actions=(lambda: chat_prefs.set_trigger_mode(msg.chat_id, action),),
115
+ )
116
+
117
+ if action == "clear":
118
+ decision = await check_admin_or_private(
119
+ cfg,
120
+ msg,
121
+ missing_sender="cannot verify sender for trigger settings.",
122
+ failed_member="failed to verify trigger permissions.",
123
+ denied="changing trigger mode is restricted to group admins.",
124
+ )
125
+ if not decision.allowed:
126
+ return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE)
127
+ if tkey is not None:
128
+ if topic_store is None:
129
+ return ActionPlan(reply_text="topic trigger settings are unavailable.")
130
+ return ActionPlan(
131
+ reply_text="topic trigger mode cleared (using chat default).",
132
+ actions=(lambda: topic_store.clear_trigger_mode(tkey[0], tkey[1]),),
133
+ )
134
+ if chat_prefs is None:
135
+ return ActionPlan(
136
+ reply_text="chat trigger settings are unavailable (no config path)."
137
+ )
138
+ return ActionPlan(
139
+ reply_text="chat trigger mode reset to `all`.",
140
+ actions=(lambda: chat_prefs.clear_trigger_mode(msg.chat_id),),
141
+ )
142
+
143
+ return ActionPlan(reply_text=TRIGGER_USAGE)
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..context import RunContext
6
+ from ..transport_runtime import TransportRuntime
7
+ from .topic_state import TopicThreadSnapshot
8
+ from .topics import _topics_scope_label
9
+
10
+ if TYPE_CHECKING:
11
+ from .bridge import TelegramBridgeConfig
12
+
13
+ __all__ = [
14
+ "_format_context",
15
+ "_format_ctx_status",
16
+ "_merge_topic_context",
17
+ "_parse_project_branch_args",
18
+ "_usage_ctx_set",
19
+ "_usage_topic",
20
+ ]
21
+
22
+
23
+ def _format_context(runtime: TransportRuntime, context: RunContext | None) -> str:
24
+ if context is None or context.project is None:
25
+ return "none"
26
+ project = runtime.project_alias_for_key(context.project)
27
+ if context.branch:
28
+ return f"{project} @{context.branch}"
29
+ return project
30
+
31
+
32
+ def _usage_ctx_set(*, chat_project: str | None) -> str:
33
+ if chat_project is not None:
34
+ return "usage: `/ctx set [@branch]`"
35
+ return "usage: `/ctx set <project> [@branch]`"
36
+
37
+
38
+ def _usage_topic(*, chat_project: str | None) -> str:
39
+ if chat_project is not None:
40
+ return "usage: `/topic @branch`"
41
+ return "usage: `/topic <project> @branch`"
42
+
43
+
44
+ def _parse_project_branch_args(
45
+ args_text: str,
46
+ *,
47
+ runtime: TransportRuntime,
48
+ require_branch: bool,
49
+ chat_project: str | None,
50
+ ) -> tuple[RunContext | None, str | None]:
51
+ from .files import split_command_args
52
+
53
+ tokens = split_command_args(args_text)
54
+ if not tokens:
55
+ return (
56
+ None,
57
+ _usage_topic(chat_project=chat_project)
58
+ if require_branch
59
+ else _usage_ctx_set(chat_project=chat_project),
60
+ )
61
+ if len(tokens) > 2:
62
+ return None, "too many arguments"
63
+ project_token: str | None = None
64
+ branch: str | None = None
65
+ first = tokens[0]
66
+ if first.startswith("@"):
67
+ branch = first[1:] or None
68
+ else:
69
+ project_token = first
70
+ if len(tokens) == 2:
71
+ second = tokens[1]
72
+ if not second.startswith("@"):
73
+ return None, "branch must be prefixed with @"
74
+ branch = second[1:] or None
75
+
76
+ project_key: str | None = None
77
+ if chat_project is not None:
78
+ if project_token is None:
79
+ project_key = chat_project
80
+ else:
81
+ normalized = runtime.normalize_project_key(project_token)
82
+ if normalized is None:
83
+ return None, f"unknown project {project_token!r}"
84
+ if normalized != chat_project:
85
+ expected = runtime.project_alias_for_key(chat_project)
86
+ return None, (f"project mismatch for this chat; expected {expected!r}.")
87
+ project_key = normalized
88
+ else:
89
+ if project_token is None:
90
+ return None, "project is required"
91
+ project_key = runtime.normalize_project_key(project_token)
92
+ if project_key is None:
93
+ return None, f"unknown project {project_token!r}"
94
+
95
+ if require_branch and not branch:
96
+ return None, "branch is required"
97
+
98
+ return RunContext(project=project_key, branch=branch), None
99
+
100
+
101
+ def _format_ctx_status(
102
+ *,
103
+ cfg: TelegramBridgeConfig,
104
+ runtime: TransportRuntime,
105
+ bound: RunContext | None,
106
+ resolved: RunContext | None,
107
+ context_source: str,
108
+ snapshot: TopicThreadSnapshot | None,
109
+ chat_project: str | None,
110
+ ) -> str:
111
+ lines = [
112
+ f"topics: enabled (scope={_topics_scope_label(cfg)})",
113
+ f"bound ctx: {_format_context(runtime, bound)}",
114
+ f"resolved ctx: {_format_context(runtime, resolved)} (source: {context_source})",
115
+ ]
116
+ if chat_project is None and bound is None:
117
+ topic_usage = (
118
+ _usage_topic(chat_project=chat_project).removeprefix("usage: ").strip()
119
+ )
120
+ ctx_usage = (
121
+ _usage_ctx_set(chat_project=chat_project).removeprefix("usage: ").strip()
122
+ )
123
+ lines.append(f"note: unbound topic — bind with {topic_usage} or {ctx_usage}")
124
+ sessions = None
125
+ if snapshot is not None and snapshot.sessions:
126
+ sessions = ", ".join(sorted(snapshot.sessions))
127
+ lines.append(f"sessions: {sessions or 'none'}")
128
+ return "\n".join(lines)
129
+
130
+
131
+ def _merge_topic_context(
132
+ *, chat_project: str | None, bound: RunContext | None
133
+ ) -> RunContext | None:
134
+ if chat_project is None:
135
+ return bound
136
+ if bound is None:
137
+ return RunContext(project=chat_project, branch=None)
138
+ if bound.project is None:
139
+ return RunContext(project=chat_project, branch=bound.branch)
140
+ return bound
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from ..context import RunContext
7
+ from ..model import EngineId
8
+ from ..transport_runtime import TransportRuntime
9
+ from .chat_prefs import ChatPrefsStore
10
+ from .topic_state import TopicStateStore
11
+
12
+ EngineSource = Literal[
13
+ "directive",
14
+ "topic_default",
15
+ "chat_default",
16
+ "project_default",
17
+ "global_default",
18
+ ]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class EngineResolution:
23
+ engine: EngineId
24
+ source: EngineSource
25
+ topic_default: EngineId | None
26
+ chat_default: EngineId | None
27
+ project_default: EngineId | None
28
+
29
+
30
+ async def resolve_engine_for_message(
31
+ *,
32
+ runtime: TransportRuntime,
33
+ context: RunContext | None,
34
+ explicit_engine: EngineId | None,
35
+ chat_id: int,
36
+ topic_key: tuple[int, int] | None,
37
+ topic_store: TopicStateStore | None,
38
+ chat_prefs: ChatPrefsStore | None,
39
+ ) -> EngineResolution:
40
+ topic_default = None
41
+ if topic_store is not None and topic_key is not None:
42
+ topic_default = await topic_store.get_default_engine(*topic_key)
43
+ chat_default = None
44
+ if chat_prefs is not None:
45
+ chat_default = await chat_prefs.get_default_engine(chat_id)
46
+ project_default = runtime.project_default_engine(context)
47
+
48
+ if explicit_engine is not None:
49
+ return EngineResolution(
50
+ engine=explicit_engine,
51
+ source="directive",
52
+ topic_default=topic_default,
53
+ chat_default=chat_default,
54
+ project_default=project_default,
55
+ )
56
+ if topic_default is not None:
57
+ return EngineResolution(
58
+ engine=topic_default,
59
+ source="topic_default",
60
+ topic_default=topic_default,
61
+ chat_default=chat_default,
62
+ project_default=project_default,
63
+ )
64
+ if chat_default is not None:
65
+ return EngineResolution(
66
+ engine=chat_default,
67
+ source="chat_default",
68
+ topic_default=topic_default,
69
+ chat_default=chat_default,
70
+ project_default=project_default,
71
+ )
72
+ if project_default is not None:
73
+ return EngineResolution(
74
+ engine=project_default,
75
+ source="project_default",
76
+ topic_default=topic_default,
77
+ chat_default=chat_default,
78
+ project_default=project_default,
79
+ )
80
+ return EngineResolution(
81
+ engine=runtime.default_engine,
82
+ source="global_default",
83
+ topic_default=topic_default,
84
+ chat_default=chat_default,
85
+ project_default=project_default,
86
+ )
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ import msgspec
7
+
8
+ OverrideSource = Literal["topic_override", "chat_default", "default"]
9
+
10
+ REASONING_LEVELS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh")
11
+ REASONING_SUPPORTED_ENGINES = frozenset({"codex"})
12
+
13
+
14
+ class EngineOverrides(msgspec.Struct, forbid_unknown_fields=False):
15
+ model: str | None = None
16
+ reasoning: str | None = None
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class OverrideValueResolution:
21
+ value: str | None
22
+ source: OverrideSource
23
+ topic_value: str | None
24
+ chat_value: str | None
25
+
26
+
27
+ def normalize_override_value(value: str | None) -> str | None:
28
+ if value is None:
29
+ return None
30
+ cleaned = value.strip()
31
+ return cleaned or None
32
+
33
+
34
+ def normalize_overrides(overrides: EngineOverrides | None) -> EngineOverrides | None:
35
+ if overrides is None:
36
+ return None
37
+ model = normalize_override_value(overrides.model)
38
+ reasoning = normalize_override_value(overrides.reasoning)
39
+ if model is None and reasoning is None:
40
+ return None
41
+ return EngineOverrides(model=model, reasoning=reasoning)
42
+
43
+
44
+ def merge_overrides(
45
+ topic_override: EngineOverrides | None,
46
+ chat_override: EngineOverrides | None,
47
+ ) -> EngineOverrides | None:
48
+ topic = normalize_overrides(topic_override)
49
+ chat = normalize_overrides(chat_override)
50
+ if topic is None and chat is None:
51
+ return None
52
+ model = None
53
+ reasoning = None
54
+ if topic is not None and topic.model is not None:
55
+ model = topic.model
56
+ elif chat is not None:
57
+ model = chat.model
58
+ if topic is not None and topic.reasoning is not None:
59
+ reasoning = topic.reasoning
60
+ elif chat is not None:
61
+ reasoning = chat.reasoning
62
+ return normalize_overrides(EngineOverrides(model=model, reasoning=reasoning))
63
+
64
+
65
+ def resolve_override_value(
66
+ *,
67
+ topic_override: EngineOverrides | None,
68
+ chat_override: EngineOverrides | None,
69
+ field: Literal["model", "reasoning"],
70
+ ) -> OverrideValueResolution:
71
+ topic_value = normalize_override_value(
72
+ getattr(topic_override, field, None) if topic_override is not None else None
73
+ )
74
+ chat_value = normalize_override_value(
75
+ getattr(chat_override, field, None) if chat_override is not None else None
76
+ )
77
+ if topic_value is not None:
78
+ return OverrideValueResolution(
79
+ value=topic_value,
80
+ source="topic_override",
81
+ topic_value=topic_value,
82
+ chat_value=chat_value,
83
+ )
84
+ if chat_value is not None:
85
+ return OverrideValueResolution(
86
+ value=chat_value,
87
+ source="chat_default",
88
+ topic_value=topic_value,
89
+ chat_value=chat_value,
90
+ )
91
+ return OverrideValueResolution(
92
+ value=None,
93
+ source="default",
94
+ topic_value=topic_value,
95
+ chat_value=chat_value,
96
+ )
97
+
98
+
99
+ def allowed_reasoning_levels(engine: str) -> tuple[str, ...]:
100
+ _ = engine
101
+ return REASONING_LEVELS
102
+
103
+
104
+ def supports_reasoning(engine: str) -> bool:
105
+ return engine in REASONING_SUPPORTED_ENGINES
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import shlex
6
+ import tempfile
7
+ import zipfile
8
+ from collections.abc import Sequence
9
+ from pathlib import Path, PurePosixPath
10
+
11
+ __all__ = [
12
+ "ZipTooLargeError",
13
+ "default_upload_name",
14
+ "default_upload_path",
15
+ "deny_reason",
16
+ "file_usage",
17
+ "format_bytes",
18
+ "normalize_relative_path",
19
+ "parse_file_command",
20
+ "parse_file_prompt",
21
+ "resolve_path_within_root",
22
+ "split_command_args",
23
+ "write_bytes_atomic",
24
+ "zip_directory",
25
+ ]
26
+
27
+
28
+ def split_command_args(text: str) -> tuple[str, ...]:
29
+ if not text.strip():
30
+ return ()
31
+ try:
32
+ return tuple(shlex.split(text))
33
+ except ValueError:
34
+ return tuple(text.split())
35
+
36
+
37
+ def file_usage() -> str:
38
+ return "usage: `/file put <path>` or `/file get <path>`"
39
+
40
+
41
+ def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
42
+ tokens = split_command_args(args_text)
43
+ if not tokens:
44
+ return None, "", file_usage()
45
+ command = tokens[0].lower()
46
+ rest = " ".join(tokens[1:]).strip()
47
+ if command not in {"put", "get"}:
48
+ return None, rest, file_usage()
49
+ return command, rest, None
50
+
51
+
52
+ def parse_file_prompt(
53
+ prompt: str, *, allow_empty: bool
54
+ ) -> tuple[str | None, bool, str | None]:
55
+ tokens = split_command_args(prompt)
56
+ force = False
57
+ parts: list[str] = []
58
+ for token in tokens:
59
+ if token == "--force":
60
+ force = True
61
+ continue
62
+ if token.startswith("--"):
63
+ return None, force, f"unknown flag: {token}"
64
+ parts.append(token)
65
+ path = " ".join(parts).strip()
66
+ if not path and not allow_empty:
67
+ return None, force, "missing path"
68
+ return (path or None), force, None
69
+
70
+
71
+ def normalize_relative_path(value: str) -> Path | None:
72
+ cleaned = value.strip()
73
+ if not cleaned:
74
+ return None
75
+ if cleaned.startswith("~"):
76
+ return None
77
+ path = Path(cleaned)
78
+ if path.is_absolute():
79
+ return None
80
+ parts = [part for part in path.parts if part not in {"", "."}]
81
+ if not parts:
82
+ return None
83
+ if ".." in parts:
84
+ return None
85
+ if ".git" in parts:
86
+ return None
87
+ return Path(*parts)
88
+
89
+
90
+ def resolve_path_within_root(root: Path, rel_path: Path) -> Path | None:
91
+ root_resolved = root.resolve(strict=False)
92
+ target = (root / rel_path).resolve(strict=False)
93
+ if not target.is_relative_to(root_resolved):
94
+ return None
95
+ return target
96
+
97
+
98
+ def deny_reason(rel_path: Path, deny_globs: Sequence[str]) -> str | None:
99
+ if ".git" in rel_path.parts:
100
+ return ".git/**"
101
+ posix = PurePosixPath(rel_path.as_posix())
102
+ for pattern in deny_globs:
103
+ if posix.match(pattern):
104
+ return pattern
105
+ return None
106
+
107
+
108
+ def format_bytes(value: int) -> str:
109
+ size = max(0.0, float(value))
110
+ units = ("b", "kb", "mb", "gb", "tb")
111
+ for unit in units:
112
+ if size < 1024 or unit == units[-1]:
113
+ if unit == "b":
114
+ return f"{int(size)} b"
115
+ if size < 10:
116
+ return f"{size:.1f} {unit}"
117
+ return f"{size:.0f} {unit}"
118
+ size /= 1024
119
+ return f"{int(size)} B"
120
+
121
+
122
+ def default_upload_name(filename: str | None, file_path: str | None) -> str:
123
+ name = Path(filename or "").name
124
+ if not name and file_path:
125
+ name = Path(file_path).name
126
+ if not name:
127
+ name = "upload.bin"
128
+ return name
129
+
130
+
131
+ def default_upload_path(
132
+ uploads_dir: str, filename: str | None, file_path: str | None
133
+ ) -> Path:
134
+ return Path(uploads_dir) / default_upload_name(filename, file_path)
135
+
136
+
137
+ def write_bytes_atomic(path: Path, payload: bytes) -> None:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ with tempfile.NamedTemporaryFile(
140
+ mode="wb", delete=False, dir=path.parent, prefix=".takopi-upload-"
141
+ ) as handle:
142
+ handle.write(payload)
143
+ temp_name = handle.name
144
+ Path(temp_name).replace(path)
145
+
146
+
147
+ class ZipTooLargeError(Exception):
148
+ pass
149
+
150
+
151
+ def zip_directory(
152
+ root: Path,
153
+ rel_path: Path,
154
+ deny_globs: Sequence[str],
155
+ *,
156
+ max_bytes: int | None = None,
157
+ ) -> bytes:
158
+ target = root / rel_path
159
+ buffer = io.BytesIO()
160
+ with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
161
+ for dirpath, _, filenames in os.walk(target, followlinks=False):
162
+ dir_path = Path(dirpath)
163
+ for filename in filenames:
164
+ item = dir_path / filename
165
+ if item.is_symlink():
166
+ continue
167
+ if not item.is_file():
168
+ continue
169
+ rel_item = rel_path / item.relative_to(target)
170
+ if deny_reason(rel_item, deny_globs) is not None:
171
+ continue
172
+ archive.write(item, arcname=rel_item.as_posix())
173
+ if max_bytes is not None and buffer.tell() > max_bytes:
174
+ raise ZipTooLargeError()
175
+ payload = buffer.getvalue()
176
+ if max_bytes is not None and len(payload) > max_bytes:
177
+ raise ZipTooLargeError()
178
+ return payload