codemaster-cli 2.2.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 (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.cli.textual_ui.windowing.history import (
4
+ build_history_widgets,
5
+ non_system_history_messages,
6
+ )
7
+ from vibe.cli.textual_ui.windowing.history_windowing import (
8
+ create_resume_plan,
9
+ should_resume_history,
10
+ sync_backfill_state,
11
+ )
12
+ from vibe.cli.textual_ui.windowing.state import (
13
+ HISTORY_RESUME_TAIL_MESSAGES,
14
+ LOAD_MORE_BATCH_SIZE,
15
+ HistoryLoadMoreManager,
16
+ SessionWindowing,
17
+ )
18
+
19
+ __all__ = [
20
+ "HISTORY_RESUME_TAIL_MESSAGES",
21
+ "LOAD_MORE_BATCH_SIZE",
22
+ "HistoryLoadMoreManager",
23
+ "SessionWindowing",
24
+ "build_history_widgets",
25
+ "create_resume_plan",
26
+ "non_system_history_messages",
27
+ "should_resume_history",
28
+ "sync_backfill_state",
29
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from weakref import WeakKeyDictionary
4
+
5
+ from textual.widget import Widget
6
+
7
+ from vibe.cli.textual_ui.widgets.messages import (
8
+ AssistantMessage,
9
+ ReasoningMessage,
10
+ UserMessage,
11
+ )
12
+ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
13
+ from vibe.core.types import LLMMessage, Role
14
+
15
+
16
+ def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
17
+ return [msg for msg in messages if msg.role != Role.system]
18
+
19
+
20
+ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
21
+ tool_call_map: dict[str, str] = {}
22
+ for msg in messages:
23
+ if msg.role != Role.assistant or not msg.tool_calls:
24
+ continue
25
+ for tool_call in msg.tool_calls:
26
+ if tool_call.id:
27
+ tool_call_map[tool_call.id] = tool_call.function.name or "unknown"
28
+ return tool_call_map
29
+
30
+
31
+ def build_history_widgets(
32
+ batch: list[LLMMessage],
33
+ tool_call_map: dict[str, str],
34
+ *,
35
+ start_index: int,
36
+ tools_collapsed: bool,
37
+ history_widget_indices: WeakKeyDictionary[Widget, int],
38
+ ) -> list[Widget]:
39
+ widgets: list[Widget] = []
40
+
41
+ for offset, msg in enumerate(batch):
42
+ history_index = start_index + offset
43
+ match msg.role:
44
+ case Role.user:
45
+ if msg.content:
46
+ widget = UserMessage(msg.content)
47
+ widgets.append(widget)
48
+ history_widget_indices[widget] = history_index
49
+
50
+ case Role.assistant:
51
+ if msg.content:
52
+ assistant_widget = AssistantMessage(msg.content)
53
+ widgets.append(assistant_widget)
54
+ history_widget_indices[assistant_widget] = history_index
55
+
56
+ if msg.tool_calls:
57
+ for tool_call in msg.tool_calls:
58
+ tool_name = tool_call.function.name or "unknown"
59
+ if tool_call.id:
60
+ tool_call_map[tool_call.id] = tool_name
61
+ widget = ToolCallMessage(tool_name=tool_name)
62
+ widgets.append(widget)
63
+ history_widget_indices[widget] = history_index
64
+
65
+ case Role.tool:
66
+ tool_name = msg.name or tool_call_map.get(
67
+ msg.tool_call_id or "", "tool"
68
+ )
69
+ widget = ToolResultMessage(
70
+ tool_name=tool_name, content=msg.content, collapsed=tools_collapsed
71
+ )
72
+ widgets.append(widget)
73
+ history_widget_indices[widget] = history_index
74
+
75
+ return widgets
76
+
77
+
78
+ def split_history_tail(
79
+ history_messages: list[LLMMessage], tail_size: int
80
+ ) -> tuple[list[LLMMessage], list[LLMMessage], int]:
81
+ tail_messages = history_messages[-tail_size:]
82
+ backfill_messages = history_messages[:-tail_size]
83
+ tail_start_index = len(history_messages) - len(tail_messages)
84
+ return tail_messages, backfill_messages, tail_start_index
85
+
86
+
87
+ def visible_history_indices(
88
+ children: list[Widget], history_widget_indices: WeakKeyDictionary[Widget, int]
89
+ ) -> list[int]:
90
+ return [
91
+ idx
92
+ for child in children
93
+ if (idx := history_widget_indices.get(child)) is not None
94
+ ]
95
+
96
+
97
+ def visible_history_widgets_count(children: list[Widget]) -> int:
98
+ history_widget_types = (
99
+ UserMessage,
100
+ AssistantMessage,
101
+ ReasoningMessage,
102
+ ToolCallMessage,
103
+ ToolResultMessage,
104
+ )
105
+ return sum(isinstance(child, history_widget_types) for child in children)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from weakref import WeakKeyDictionary
5
+
6
+ from textual.widget import Widget
7
+
8
+ from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
9
+ from vibe.cli.textual_ui.windowing.history import (
10
+ build_tool_call_map,
11
+ split_history_tail,
12
+ visible_history_indices,
13
+ visible_history_widgets_count,
14
+ )
15
+ from vibe.cli.textual_ui.windowing.state import SessionWindowing
16
+ from vibe.core.types import LLMMessage
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class HistoryResumePlan:
21
+ tool_call_map: dict[str, str]
22
+ tail_messages: list[LLMMessage]
23
+ backfill_messages: list[LLMMessage]
24
+ tail_start_index: int
25
+
26
+ @property
27
+ def has_backfill(self) -> bool:
28
+ return bool(self.backfill_messages)
29
+
30
+
31
+ def should_resume_history(messages_children: list[Widget]) -> bool:
32
+ allowed_pre_existing_types = (WhatsNewMessage,)
33
+ return all(
34
+ isinstance(child, allowed_pre_existing_types) for child in messages_children
35
+ )
36
+
37
+
38
+ def create_resume_plan(
39
+ history_messages: list[LLMMessage], tail_size: int
40
+ ) -> HistoryResumePlan | None:
41
+ if not history_messages:
42
+ return None
43
+ tail_messages, backfill_messages, tail_start_index = split_history_tail(
44
+ history_messages, tail_size
45
+ )
46
+ return HistoryResumePlan(
47
+ tool_call_map=build_tool_call_map(history_messages),
48
+ tail_messages=tail_messages,
49
+ backfill_messages=backfill_messages,
50
+ tail_start_index=tail_start_index,
51
+ )
52
+
53
+
54
+ def sync_backfill_state(
55
+ *,
56
+ history_messages: list[LLMMessage],
57
+ messages_children: list[Widget],
58
+ history_widget_indices: WeakKeyDictionary[Widget, int],
59
+ windowing: SessionWindowing,
60
+ ) -> tuple[bool, dict[str, str] | None]:
61
+ if not history_messages:
62
+ windowing.reset()
63
+ return False, None
64
+ visible_indices = visible_history_indices(messages_children, history_widget_indices)
65
+ visible_history_widgets = visible_history_widgets_count(messages_children)
66
+ has_backfill = windowing.recompute_backfill(
67
+ history_messages,
68
+ visible_indices=visible_indices,
69
+ visible_history_widgets_count=visible_history_widgets,
70
+ )
71
+ return has_backfill, build_tool_call_map(history_messages)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from textual.widget import Widget
6
+
7
+ from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreMessage
8
+ from vibe.core.types import LLMMessage
9
+
10
+ HISTORY_RESUME_TAIL_MESSAGES = 20
11
+ LOAD_MORE_BATCH_SIZE = 10
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class LoadMoreBatch:
16
+ start_index: int
17
+ messages: list[LLMMessage]
18
+
19
+
20
+ class SessionWindowing:
21
+ def __init__(self, load_more_batch_size: int) -> None:
22
+ self.load_more_batch_size = load_more_batch_size
23
+ self._backfill_messages: list[LLMMessage] = []
24
+ self._backfill_cursor = 0
25
+
26
+ @property
27
+ def remaining(self) -> int:
28
+ return self._backfill_cursor
29
+
30
+ @property
31
+ def has_backfill(self) -> bool:
32
+ return self._backfill_cursor > 0
33
+
34
+ def reset(self) -> None:
35
+ self._backfill_messages = []
36
+ self._backfill_cursor = 0
37
+
38
+ def set_backfill(self, backfill_messages: list[LLMMessage]) -> None:
39
+ self._backfill_messages = backfill_messages
40
+ self._backfill_cursor = len(backfill_messages)
41
+
42
+ def next_load_more_batch(self) -> LoadMoreBatch | None:
43
+ if self._backfill_cursor == 0:
44
+ return None
45
+ start_index = max(self._backfill_cursor - self.load_more_batch_size, 0)
46
+ batch = self._backfill_messages[start_index : self._backfill_cursor]
47
+ self._backfill_cursor = start_index
48
+ if not batch:
49
+ return None
50
+ return LoadMoreBatch(start_index=start_index, messages=batch)
51
+
52
+ def recompute_backfill(
53
+ self,
54
+ history_messages: list[LLMMessage],
55
+ visible_indices: list[int],
56
+ visible_history_widgets_count: int,
57
+ ) -> bool:
58
+ if not history_messages:
59
+ self._backfill_messages = []
60
+ self._backfill_cursor = 0
61
+ return False
62
+ if visible_indices:
63
+ backfill_end = min(visible_indices)
64
+ else:
65
+ backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
66
+ self._backfill_messages = history_messages[:backfill_end]
67
+ self._backfill_cursor = len(self._backfill_messages)
68
+ return self._backfill_cursor > 0
69
+
70
+
71
+ class HistoryLoadMoreManager:
72
+ def __init__(self) -> None:
73
+ self.widget: HistoryLoadMoreMessage | None = None
74
+
75
+ async def show(self, messages_area: Widget, remaining: int) -> None:
76
+ if self.widget is None:
77
+ widget = HistoryLoadMoreMessage()
78
+ await messages_area.mount(widget, before=0)
79
+ self.widget = widget
80
+ self.set_remaining(remaining)
81
+
82
+ async def hide(self) -> None:
83
+ if self.widget is None:
84
+ return
85
+ if self.widget.parent:
86
+ await self.widget.remove()
87
+ self.widget = None
88
+
89
+ async def set_visible(
90
+ self, messages_area: Widget, *, visible: bool, remaining: int
91
+ ) -> None:
92
+ if visible:
93
+ await self.show(messages_area, remaining)
94
+ return
95
+ await self.hide()
96
+
97
+ def set_enabled(self, enabled: bool) -> None:
98
+ if self.widget is None:
99
+ return
100
+ self.widget.set_enabled(enabled)
101
+
102
+ def set_remaining(self, remaining: int) -> None:
103
+ if self.widget is None:
104
+ return
105
+ self.widget.set_remaining(remaining)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
4
+ FileSystemUpdateCacheRepository,
5
+ )
6
+ from vibe.cli.update_notifier.adapters.github_update_gateway import GitHubUpdateGateway
7
+ from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
8
+ from vibe.cli.update_notifier.ports.update_cache_repository import (
9
+ UpdateCache,
10
+ UpdateCacheRepository,
11
+ )
12
+ from vibe.cli.update_notifier.ports.update_gateway import (
13
+ DEFAULT_GATEWAY_MESSAGES,
14
+ Update,
15
+ UpdateGateway,
16
+ UpdateGatewayCause,
17
+ UpdateGatewayError,
18
+ )
19
+ from vibe.cli.update_notifier.update import (
20
+ UpdateAvailability,
21
+ UpdateError,
22
+ get_update_if_available,
23
+ )
24
+ from vibe.cli.update_notifier.whats_new import (
25
+ load_whats_new_content,
26
+ mark_version_as_seen,
27
+ should_show_whats_new,
28
+ )
29
+
30
+ __all__ = [
31
+ "DEFAULT_GATEWAY_MESSAGES",
32
+ "FileSystemUpdateCacheRepository",
33
+ "GitHubUpdateGateway",
34
+ "PyPIUpdateGateway",
35
+ "Update",
36
+ "UpdateAvailability",
37
+ "UpdateCache",
38
+ "UpdateCacheRepository",
39
+ "UpdateError",
40
+ "UpdateGateway",
41
+ "UpdateGatewayCause",
42
+ "UpdateGatewayError",
43
+ "get_update_if_available",
44
+ "load_whats_new_content",
45
+ "mark_version_as_seen",
46
+ "should_show_whats_new",
47
+ ]
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from vibe.cli.update_notifier.ports.update_cache_repository import (
8
+ UpdateCache,
9
+ UpdateCacheRepository,
10
+ )
11
+ from vibe.core.paths.global_paths import VIBE_HOME
12
+
13
+
14
+ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
15
+ def __init__(self, base_path: Path | str | None = None) -> None:
16
+ self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
17
+ self._cache_file = self._base_path / "update_cache.json"
18
+
19
+ async def get(self) -> UpdateCache | None:
20
+ try:
21
+ content = await asyncio.to_thread(self._cache_file.read_text)
22
+ except OSError:
23
+ return None
24
+
25
+ try:
26
+ data = json.loads(content)
27
+ latest_version = data.get("latest_version")
28
+ stored_at_timestamp = data.get("stored_at_timestamp")
29
+ seen_whats_new_version = data.get("seen_whats_new_version")
30
+ except (TypeError, json.JSONDecodeError):
31
+ return None
32
+
33
+ if not isinstance(latest_version, str) or not isinstance(
34
+ stored_at_timestamp, int
35
+ ):
36
+ return None
37
+
38
+ if (
39
+ not isinstance(seen_whats_new_version, str)
40
+ and seen_whats_new_version is not None
41
+ ):
42
+ seen_whats_new_version = None
43
+
44
+ return UpdateCache(
45
+ latest_version=latest_version,
46
+ stored_at_timestamp=stored_at_timestamp,
47
+ seen_whats_new_version=seen_whats_new_version,
48
+ )
49
+
50
+ async def set(self, update_cache: UpdateCache) -> None:
51
+ try:
52
+ payload = json.dumps({
53
+ "latest_version": update_cache.latest_version,
54
+ "stored_at_timestamp": update_cache.stored_at_timestamp,
55
+ "seen_whats_new_version": update_cache.seen_whats_new_version,
56
+ })
57
+ await asyncio.to_thread(self._cache_file.write_text, payload)
58
+ except OSError:
59
+ return None
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from vibe.cli.update_notifier.ports.update_gateway import (
6
+ Update,
7
+ UpdateGateway,
8
+ UpdateGatewayCause,
9
+ UpdateGatewayError,
10
+ )
11
+
12
+
13
+ class GitHubUpdateGateway(UpdateGateway):
14
+ def __init__(
15
+ self,
16
+ owner: str,
17
+ repository: str,
18
+ *,
19
+ token: str | None = None,
20
+ client: httpx.AsyncClient | None = None,
21
+ timeout: float = 5.0,
22
+ base_url: str = "https://api.github.com",
23
+ ) -> None:
24
+ self._owner = owner
25
+ self._repository = repository
26
+ self._token = token
27
+ self._client = client
28
+ self._timeout = timeout
29
+ self._base_url = base_url.rstrip("/")
30
+
31
+ async def fetch_update(self) -> Update | None:
32
+ headers = {
33
+ "Accept": "application/vnd.github+json",
34
+ "User-Agent": "mistral-vibe-update-notifier",
35
+ }
36
+ if self._token:
37
+ headers["Authorization"] = f"Bearer {self._token}"
38
+
39
+ request_path = f"/repos/{self._owner}/{self._repository}/releases"
40
+
41
+ try:
42
+ if self._client is not None:
43
+ response = await self._client.get(
44
+ f"{self._base_url}{request_path}",
45
+ headers=headers,
46
+ timeout=self._timeout,
47
+ )
48
+ else:
49
+ async with httpx.AsyncClient(
50
+ base_url=self._base_url, timeout=self._timeout
51
+ ) as client:
52
+ response = await client.get(request_path, headers=headers)
53
+ except httpx.RequestError as exc:
54
+ raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
55
+
56
+ rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
57
+ if response.status_code == httpx.codes.TOO_MANY_REQUESTS or (
58
+ rate_limit_remaining is not None and rate_limit_remaining == "0"
59
+ ):
60
+ raise UpdateGatewayError(cause=UpdateGatewayCause.TOO_MANY_REQUESTS)
61
+
62
+ if response.status_code == httpx.codes.FORBIDDEN:
63
+ raise UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
64
+
65
+ if response.status_code == httpx.codes.NOT_FOUND:
66
+ raise UpdateGatewayError(
67
+ cause=UpdateGatewayCause.NOT_FOUND,
68
+ message="Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
69
+ )
70
+
71
+ if response.is_error:
72
+ raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
73
+
74
+ try:
75
+ data = response.json()
76
+ except ValueError as exc:
77
+ raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
78
+
79
+ if not data:
80
+ return None
81
+
82
+ # pick the most recently published non-prerelease and non-draft release
83
+ # github "list releases" API most likely returns ordered results, but this is not guaranteed
84
+ for release in sorted(
85
+ data, key=lambda x: x.get("published_at") or "", reverse=True
86
+ ):
87
+ if release.get("prerelease") or release.get("draft"):
88
+ continue
89
+ if version := _extract_version(release.get("tag_name")):
90
+ return Update(latest_version=version)
91
+
92
+ return None
93
+
94
+
95
+ def _extract_version(tag_name: str | None) -> str | None:
96
+ if not tag_name:
97
+ return None
98
+ tag = tag_name.strip()
99
+ if not tag:
100
+ return None
101
+ return tag[1:] if tag.startswith(("v", "V")) else tag
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ from packaging.utils import parse_sdist_filename, parse_wheel_filename
5
+ from packaging.version import InvalidVersion, Version
6
+
7
+ from vibe.cli.update_notifier.ports.update_gateway import (
8
+ Update,
9
+ UpdateGateway,
10
+ UpdateGatewayCause,
11
+ UpdateGatewayError,
12
+ )
13
+
14
+ _STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
15
+ httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
16
+ httpx.codes.FORBIDDEN: UpdateGatewayCause.FORBIDDEN,
17
+ httpx.codes.TOO_MANY_REQUESTS: UpdateGatewayCause.TOO_MANY_REQUESTS,
18
+ }
19
+
20
+
21
+ class PyPIUpdateGateway(UpdateGateway):
22
+ def __init__(
23
+ self,
24
+ project_name: str,
25
+ *,
26
+ client: httpx.AsyncClient | None = None,
27
+ timeout: float = 5.0,
28
+ base_url: str = "https://pypi.org",
29
+ ) -> None:
30
+ self._project_name = project_name
31
+ self._client = client
32
+ self._timeout = timeout
33
+ self._base_url = base_url.rstrip("/")
34
+
35
+ async def fetch_update(self) -> Update | None:
36
+ response = await self._fetch()
37
+ self._raise_gateway_error_if_any(response)
38
+
39
+ try:
40
+ data = response.json()
41
+ except ValueError as exc:
42
+ raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
43
+
44
+ versions = data.get("versions") or []
45
+ files = data.get("files") or []
46
+
47
+ non_yanked_versions: set[Version] = set()
48
+ for file in files:
49
+ if not isinstance(file, dict) or file.get("yanked") is True:
50
+ continue
51
+ filename = file.get("filename")
52
+ if not isinstance(filename, str):
53
+ continue
54
+ parsed_version = _parse_filename_version(filename)
55
+ if parsed_version is not None:
56
+ non_yanked_versions.add(parsed_version)
57
+
58
+ valid_versions: list[Version] = []
59
+ for raw_version in versions:
60
+ try:
61
+ valid_versions.append(Version(str(raw_version)))
62
+ except InvalidVersion:
63
+ continue
64
+
65
+ for version in sorted(valid_versions, reverse=True):
66
+ if version in non_yanked_versions:
67
+ return Update(latest_version=str(version))
68
+
69
+ return None
70
+
71
+ async def _fetch(self) -> httpx.Response:
72
+ headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
73
+ request_path = f"/simple/{self._project_name}/"
74
+
75
+ try:
76
+ if self._client is not None:
77
+ return await self._client.get(
78
+ f"{self._base_url}{request_path}",
79
+ headers=headers,
80
+ timeout=self._timeout,
81
+ )
82
+
83
+ async with httpx.AsyncClient(
84
+ base_url=self._base_url, timeout=self._timeout
85
+ ) as client:
86
+ return await client.get(request_path, headers=headers)
87
+ except httpx.RequestError as exc:
88
+ raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
89
+
90
+ def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
91
+ if response.status_code in _STATUS_CAUSES:
92
+ raise UpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
93
+
94
+ if response.is_error:
95
+ raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
96
+
97
+
98
+ def _parse_filename_version(filename: str) -> Version | None:
99
+ try:
100
+ _, version, *_ = parse_wheel_filename(filename)
101
+ return Version(str(version))
102
+ except Exception:
103
+ try:
104
+ _, sdist_version = parse_sdist_filename(filename)
105
+ return Version(str(sdist_version))
106
+ except Exception:
107
+ return None
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class UpdateCache:
9
+ latest_version: str
10
+ stored_at_timestamp: int
11
+ seen_whats_new_version: str | None = None
12
+
13
+
14
+ class UpdateCacheRepository(Protocol):
15
+ async def get(self) -> UpdateCache | None: ...
16
+ async def set(self, update_cache: UpdateCache) -> None: ...
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum, auto
5
+ from typing import Protocol
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Update:
10
+ latest_version: str
11
+
12
+
13
+ class UpdateGatewayCause(StrEnum):
14
+ @staticmethod
15
+ def _generate_next_value_(
16
+ name: str, start: int, count: int, last_values: list[str]
17
+ ) -> str:
18
+ return name.lower()
19
+
20
+ TOO_MANY_REQUESTS = auto()
21
+ FORBIDDEN = auto()
22
+ NOT_FOUND = auto()
23
+ REQUEST_FAILED = auto()
24
+ ERROR_RESPONSE = auto()
25
+ INVALID_RESPONSE = auto()
26
+ UNKNOWN = auto()
27
+
28
+
29
+ DEFAULT_GATEWAY_MESSAGES: dict[UpdateGatewayCause, str] = {
30
+ UpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
31
+ UpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
32
+ UpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
33
+ UpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
34
+ UpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
35
+ UpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
36
+ UpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
37
+ }
38
+
39
+
40
+ class UpdateGatewayError(Exception):
41
+ def __init__(
42
+ self, *, cause: UpdateGatewayCause, message: str | None = None
43
+ ) -> None:
44
+ self.cause = cause
45
+ self.user_message = message
46
+ detail = message or DEFAULT_GATEWAY_MESSAGES.get(
47
+ cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
48
+ )
49
+ super().__init__(detail)
50
+
51
+
52
+ class UpdateGateway(Protocol):
53
+ async def fetch_update(self) -> Update | None: ...