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,31 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.core.agents.manager import AgentManager
4
+ from vibe.core.agents.models import (
5
+ ACCEPT_EDITS,
6
+ AUTO_APPROVE,
7
+ BUILTIN_AGENTS,
8
+ DEFAULT,
9
+ EXPLORE,
10
+ PLAN,
11
+ PLAN_AGENT_TOOLS,
12
+ AgentProfile,
13
+ AgentSafety,
14
+ AgentType,
15
+ BuiltinAgentName,
16
+ )
17
+
18
+ __all__ = [
19
+ "ACCEPT_EDITS",
20
+ "AUTO_APPROVE",
21
+ "BUILTIN_AGENTS",
22
+ "DEFAULT",
23
+ "EXPLORE",
24
+ "PLAN",
25
+ "PLAN_AGENT_TOOLS",
26
+ "AgentManager",
27
+ "AgentProfile",
28
+ "AgentSafety",
29
+ "AgentType",
30
+ "BuiltinAgentName",
31
+ ]
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from logging import getLogger
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from vibe.core.agents.models import (
9
+ BUILTIN_AGENTS,
10
+ AgentProfile,
11
+ AgentType,
12
+ BuiltinAgentName,
13
+ )
14
+ from vibe.core.paths.config_paths import resolve_local_agents_dir
15
+ from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
16
+ from vibe.core.utils import name_matches
17
+
18
+ if TYPE_CHECKING:
19
+ from vibe.core.config import VibeConfig
20
+
21
+ logger = getLogger("vibe")
22
+
23
+
24
+ class AgentManager:
25
+ def __init__(
26
+ self,
27
+ config_getter: Callable[[], VibeConfig],
28
+ initial_agent: str = BuiltinAgentName.DEFAULT,
29
+ ) -> None:
30
+ self._config_getter = config_getter
31
+ self._search_paths = self._compute_search_paths(self._config)
32
+ self._available: dict[str, AgentProfile] = self._discover_agents()
33
+
34
+ custom_count = len(self._available) - len(BUILTIN_AGENTS)
35
+ if custom_count > 0:
36
+ custom_names = [
37
+ name for name in self._available if name not in BUILTIN_AGENTS
38
+ ]
39
+ logger.info(
40
+ "Discovered custom agents %s in %s",
41
+ " ".join(custom_names),
42
+ " ".join(str(p) for p in self._search_paths),
43
+ )
44
+
45
+ self.active_profile = self._available.get(
46
+ initial_agent, self._available[BuiltinAgentName.DEFAULT]
47
+ )
48
+ self._cached_config: VibeConfig | None = None
49
+
50
+ @property
51
+ def _config(self) -> VibeConfig:
52
+ return self._config_getter()
53
+
54
+ @property
55
+ def available_agents(self) -> dict[str, AgentProfile]:
56
+ if self._config.enabled_agents:
57
+ return {
58
+ name: profile
59
+ for name, profile in self._available.items()
60
+ if name_matches(name, self._config.enabled_agents)
61
+ }
62
+ if self._config.disabled_agents:
63
+ return {
64
+ name: profile
65
+ for name, profile in self._available.items()
66
+ if not name_matches(name, self._config.disabled_agents)
67
+ }
68
+ return dict(self._available)
69
+
70
+ @property
71
+ def config(self) -> VibeConfig:
72
+ if self._cached_config is None:
73
+ self._cached_config = self.active_profile.apply_to_config(self._config)
74
+ return self._cached_config
75
+
76
+ def switch_profile(self, name: str) -> None:
77
+ self.active_profile = self.get_agent(name)
78
+ self._cached_config = None
79
+
80
+ def invalidate_config(self) -> None:
81
+ self._cached_config = None
82
+
83
+ @staticmethod
84
+ def _compute_search_paths(config: VibeConfig) -> list[Path]:
85
+ paths: list[Path] = []
86
+ for path in config.agent_paths:
87
+ if path.is_dir():
88
+ paths.append(path)
89
+ if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
90
+ paths.append(agents_dir)
91
+ if GLOBAL_AGENTS_DIR.path.is_dir():
92
+ paths.append(GLOBAL_AGENTS_DIR.path)
93
+ unique: list[Path] = []
94
+ for p in paths:
95
+ rp = p.resolve()
96
+ if rp not in unique:
97
+ unique.append(rp)
98
+ return unique
99
+
100
+ def _discover_agents(self) -> dict[str, AgentProfile]:
101
+ agents: dict[str, AgentProfile] = dict(BUILTIN_AGENTS)
102
+
103
+ for base in self._search_paths:
104
+ if not base.is_dir():
105
+ continue
106
+ for agent_file in base.glob("*.toml"):
107
+ if not agent_file.is_file():
108
+ continue
109
+ if (agent := self._try_load_agent(agent_file)) is not None:
110
+ if agent.name in BUILTIN_AGENTS:
111
+ logger.info(
112
+ "Custom agent '%s' overrides builtin agent", agent.name
113
+ )
114
+ elif agent.name in agents:
115
+ logger.debug(
116
+ "Skipping duplicate agent '%s' at %s",
117
+ agent.name,
118
+ agent_file,
119
+ )
120
+ continue
121
+ agents[agent.name] = agent
122
+
123
+ return agents
124
+
125
+ def _try_load_agent(self, agent_file: Path) -> AgentProfile | None:
126
+ try:
127
+ agent = AgentProfile.from_toml(agent_file)
128
+ agent.apply_to_config(self._config)
129
+ return agent
130
+ except Exception as e:
131
+ logger.warning("Failed to load agent at %s: %s", agent_file, e)
132
+ return None
133
+
134
+ def get_agent(self, name: str) -> AgentProfile:
135
+ if agent := self.available_agents.get(name):
136
+ return agent
137
+ raise ValueError(f"Agent '{name}' not found")
138
+
139
+ def get_subagents(self) -> list[AgentProfile]:
140
+ return [
141
+ a
142
+ for a in self.available_agents.values()
143
+ if a.agent_type == AgentType.SUBAGENT
144
+ ]
145
+
146
+ def get_agent_order(self) -> list[str]:
147
+ builtin_order: list[str] = [
148
+ BuiltinAgentName.DEFAULT,
149
+ BuiltinAgentName.PLAN,
150
+ BuiltinAgentName.ACCEPT_EDITS,
151
+ BuiltinAgentName.AUTO_APPROVE,
152
+ ]
153
+ primary_agents = [
154
+ name
155
+ for name, agent in self.available_agents.items()
156
+ if agent.agent_type == AgentType.AGENT
157
+ ]
158
+ order = [name for name in builtin_order if name in primary_agents]
159
+ custom = sorted(name for name in primary_agents if name not in builtin_order)
160
+ return order + custom
161
+
162
+ def next_agent(self, current: AgentProfile) -> AgentProfile:
163
+ order = self.get_agent_order()
164
+ idx = order.index(current.name) if current.name in order else -1
165
+ return self.available_agents[order[(idx + 1) % len(order)]]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum, auto
5
+ from pathlib import Path
6
+ import tomllib
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from vibe.core.config import VibeConfig
11
+
12
+
13
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
14
+ result = base.copy()
15
+ for key, value in override.items():
16
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
17
+ result[key] = _deep_merge(result[key], value)
18
+ else:
19
+ result[key] = value
20
+ return result
21
+
22
+
23
+ class AgentSafety(StrEnum):
24
+ SAFE = auto()
25
+ NEUTRAL = auto()
26
+ DESTRUCTIVE = auto()
27
+ YOLO = auto()
28
+
29
+
30
+ class AgentType(StrEnum):
31
+ AGENT = auto()
32
+ SUBAGENT = auto()
33
+
34
+
35
+ class BuiltinAgentName(StrEnum):
36
+ DEFAULT = "default"
37
+ PLAN = "plan"
38
+ ACCEPT_EDITS = "accept-edits"
39
+ AUTO_APPROVE = "auto-approve"
40
+ EXPLORE = "explore"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AgentProfile:
45
+ name: str
46
+ display_name: str
47
+ description: str
48
+ safety: AgentSafety
49
+ agent_type: AgentType = AgentType.AGENT
50
+ overrides: dict[str, Any] = field(default_factory=dict)
51
+
52
+ def apply_to_config(self, base: VibeConfig) -> VibeConfig:
53
+ from vibe.core.config import VibeConfig as VC
54
+
55
+ merged = _deep_merge(base.model_dump(), self.overrides)
56
+ return VC.model_validate(merged)
57
+
58
+ @classmethod
59
+ def from_toml(cls, path: Path) -> AgentProfile:
60
+ with path.open("rb") as f:
61
+ data = tomllib.load(f)
62
+ return cls(
63
+ name=path.stem,
64
+ display_name=data.pop("display_name", path.stem.replace("-", " ").title()),
65
+ description=data.pop("description", ""),
66
+ safety=AgentSafety(data.pop("safety", AgentSafety.NEUTRAL)),
67
+ agent_type=AgentType(data.pop("agent_type", AgentType.AGENT)),
68
+ overrides=data,
69
+ )
70
+
71
+
72
+ PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
73
+
74
+ DEFAULT = AgentProfile(
75
+ BuiltinAgentName.DEFAULT,
76
+ "Default",
77
+ "Requires approval for tool executions",
78
+ AgentSafety.NEUTRAL,
79
+ )
80
+ PLAN = AgentProfile(
81
+ BuiltinAgentName.PLAN,
82
+ "Plan",
83
+ "Read-only agent for exploration and planning",
84
+ AgentSafety.SAFE,
85
+ overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
86
+ )
87
+ ACCEPT_EDITS = AgentProfile(
88
+ BuiltinAgentName.ACCEPT_EDITS,
89
+ "Accept Edits",
90
+ "Auto-approves file edits only",
91
+ AgentSafety.DESTRUCTIVE,
92
+ overrides={
93
+ "tools": {
94
+ "write_file": {"permission": "always"},
95
+ "search_replace": {"permission": "always"},
96
+ }
97
+ },
98
+ )
99
+ AUTO_APPROVE = AgentProfile(
100
+ BuiltinAgentName.AUTO_APPROVE,
101
+ "Auto Approve",
102
+ "Auto-approves all tool executions",
103
+ AgentSafety.YOLO,
104
+ overrides={"auto_approve": True},
105
+ )
106
+
107
+ EXPLORE = AgentProfile(
108
+ name=BuiltinAgentName.EXPLORE,
109
+ display_name="Explore",
110
+ description="Read-only subagent for codebase exploration",
111
+ safety=AgentSafety.SAFE,
112
+ agent_type=AgentType.SUBAGENT,
113
+ overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
114
+ )
115
+
116
+ BUILTIN_AGENTS: dict[str, AgentProfile] = {
117
+ BuiltinAgentName.DEFAULT: DEFAULT,
118
+ BuiltinAgentName.PLAN: PLAN,
119
+ BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
120
+ BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
121
+ BuiltinAgentName.EXPLORE: EXPLORE,
122
+ }
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.core.auth.crypto import EncryptedPayload, decrypt, encrypt
4
+ from vibe.core.auth.github import GitHubAuthProvider
5
+
6
+ __all__ = ["EncryptedPayload", "GitHubAuthProvider", "decrypt", "encrypt"]
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from dataclasses import dataclass
6
+ import os
7
+
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import padding
10
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
11
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
+
13
+ _AES_KEY_SIZE = 32
14
+ _NONCE_SIZE = 12
15
+ _MIN_RSA_KEY_SIZE = 2048
16
+ _MAX_ENCRYPTED_KEY_SIZE = 1024
17
+ _MAX_CIPHERTEXT_SIZE = 2 * 1024 * 1024 # Workflow transport limit: 2MB
18
+ _PAYLOAD_VERSION = 1
19
+ _ALG = "RSA-OAEP-SHA256"
20
+ _ENC = "A256GCM"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class EncryptedPayload:
25
+ encrypted_key: str
26
+ nonce: str
27
+ ciphertext: str
28
+ version: int | None = None
29
+ alg: str | None = None
30
+ enc: str | None = None
31
+ kid: str | None = None
32
+ purpose: str | None = None
33
+
34
+
35
+ def _b64decode_strict(value: str, field_name: str) -> bytes:
36
+ try:
37
+ return base64.b64decode(value, validate=True)
38
+ except (binascii.Error, ValueError) as exc:
39
+ raise ValueError(f"Invalid base64 for {field_name}") from exc
40
+
41
+
42
+ def _validate_payload_lengths(
43
+ encrypted_key: bytes, nonce: bytes, ciphertext: bytes
44
+ ) -> None:
45
+ if len(encrypted_key) > _MAX_ENCRYPTED_KEY_SIZE:
46
+ raise ValueError("Encrypted key too large")
47
+ if len(nonce) != _NONCE_SIZE:
48
+ raise ValueError("Invalid nonce size")
49
+ if not ciphertext:
50
+ raise ValueError("Ciphertext is empty")
51
+ if len(ciphertext) > _MAX_CIPHERTEXT_SIZE:
52
+ raise ValueError("Ciphertext exceeds maximum allowed size")
53
+
54
+
55
+ def _build_aad(payload: EncryptedPayload) -> bytes | None:
56
+ if payload.version is None or payload.version <= 0:
57
+ return None
58
+ alg = payload.alg or _ALG
59
+ enc = payload.enc or _ENC
60
+ parts = [f"v={payload.version}", f"alg={alg}", f"enc={enc}"]
61
+ if payload.kid:
62
+ parts.append(f"kid={payload.kid}")
63
+ if payload.purpose:
64
+ parts.append(f"purpose={payload.purpose}")
65
+ return "|".join(parts).encode("utf-8")
66
+
67
+
68
+ def encrypt(plaintext: str, public_key_pem: bytes) -> EncryptedPayload:
69
+ public_key = serialization.load_pem_public_key(public_key_pem)
70
+ if not isinstance(public_key, RSAPublicKey):
71
+ raise TypeError("Expected RSA public key")
72
+
73
+ if public_key.key_size < _MIN_RSA_KEY_SIZE:
74
+ raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
75
+
76
+ aes_key = os.urandom(_AES_KEY_SIZE)
77
+ nonce = os.urandom(_NONCE_SIZE)
78
+
79
+ payload = EncryptedPayload(
80
+ encrypted_key="",
81
+ nonce="",
82
+ ciphertext="",
83
+ version=_PAYLOAD_VERSION,
84
+ alg=_ALG,
85
+ enc=_ENC,
86
+ )
87
+ aad = _build_aad(payload)
88
+ aesgcm = AESGCM(aes_key)
89
+ ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), aad)
90
+
91
+ encrypted_key = public_key.encrypt(
92
+ aes_key,
93
+ padding.OAEP(
94
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
95
+ algorithm=hashes.SHA256(),
96
+ label=None,
97
+ ),
98
+ )
99
+
100
+ return EncryptedPayload(
101
+ encrypted_key=base64.b64encode(encrypted_key).decode("ascii"),
102
+ nonce=base64.b64encode(nonce).decode("ascii"),
103
+ ciphertext=base64.b64encode(ciphertext).decode("ascii"),
104
+ version=payload.version,
105
+ alg=payload.alg,
106
+ enc=payload.enc,
107
+ )
108
+
109
+
110
+ def decrypt(payload: EncryptedPayload, private_key_pem: bytes) -> str:
111
+ private_key = serialization.load_pem_private_key(private_key_pem, password=None)
112
+ if not isinstance(private_key, RSAPrivateKey):
113
+ raise TypeError("Expected RSA private key")
114
+
115
+ if private_key.key_size < _MIN_RSA_KEY_SIZE:
116
+ raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
117
+
118
+ encrypted_key = _b64decode_strict(payload.encrypted_key, "encrypted_key")
119
+ nonce = _b64decode_strict(payload.nonce, "nonce")
120
+ ciphertext = _b64decode_strict(payload.ciphertext, "ciphertext")
121
+ _validate_payload_lengths(encrypted_key, nonce, ciphertext)
122
+
123
+ aes_key = private_key.decrypt(
124
+ encrypted_key,
125
+ padding.OAEP(
126
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
127
+ algorithm=hashes.SHA256(),
128
+ label=None,
129
+ ),
130
+ )
131
+
132
+ if len(aes_key) != _AES_KEY_SIZE:
133
+ raise ValueError("Invalid AES key size after decryption")
134
+
135
+ aesgcm = AESGCM(aes_key)
136
+ aad = _build_aad(payload)
137
+ return aesgcm.decrypt(nonce, ciphertext, aad).decode("utf-8")
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ import types
6
+ import webbrowser
7
+
8
+ import httpx
9
+ import keyring
10
+ import keyring.errors
11
+
12
+ GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT"
13
+
14
+ _SERVICE_NAME = "vibe"
15
+ _KEYRING_USERNAME = "github_token"
16
+ _DEVICE_CODE_URL = "https://github.com/login/device/code"
17
+ _TOKEN_URL = "https://github.com/login/oauth/access_token"
18
+ _VALIDATE_URL = "https://api.github.com/user"
19
+ _SCOPES = "repo read:org write:org workflow read:user user:email"
20
+
21
+
22
+ class GitHubAuthError(Exception):
23
+ pass
24
+
25
+
26
+ @dataclass
27
+ class DeviceFlowInfo:
28
+ user_code: str
29
+ verification_uri: str
30
+
31
+
32
+ @dataclass
33
+ class DeviceFlowHandle:
34
+ device_code: str
35
+ expires_in: int
36
+ info: DeviceFlowInfo
37
+
38
+
39
+ class GitHubAuthProvider:
40
+ def __init__(
41
+ self,
42
+ client_id: str = GITHUB_CLIENT_ID,
43
+ *,
44
+ client: httpx.AsyncClient | None = None,
45
+ timeout: float = 60.0,
46
+ ) -> None:
47
+ self._client_id = client_id
48
+ self._client = client
49
+ self._owns_client = client is None
50
+ self._timeout = timeout
51
+
52
+ async def __aenter__(self) -> GitHubAuthProvider:
53
+ if self._client is None:
54
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
55
+ return self
56
+
57
+ async def __aexit__(
58
+ self,
59
+ exc_type: type[BaseException] | None,
60
+ exc_val: BaseException | None,
61
+ exc_tb: types.TracebackType | None,
62
+ ) -> None:
63
+ if self._owns_client and self._client:
64
+ await self._client.aclose()
65
+ self._client = None
66
+
67
+ def _get_client(self) -> httpx.AsyncClient:
68
+ if self._client is None:
69
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
70
+ self._owns_client = True
71
+ return self._client
72
+
73
+ def get_token(self) -> str | None:
74
+ try:
75
+ return keyring.get_password(_SERVICE_NAME, _KEYRING_USERNAME)
76
+ except keyring.errors.KeyringError:
77
+ return None
78
+
79
+ def has_token(self) -> bool:
80
+ return bool(self.get_token())
81
+
82
+ def delete_token(self) -> None:
83
+ try:
84
+ keyring.delete_password(_SERVICE_NAME, _KEYRING_USERNAME)
85
+ except keyring.errors.KeyringError:
86
+ pass
87
+
88
+ async def get_valid_token(self) -> str | None:
89
+ token = self.get_token()
90
+ if not token:
91
+ return None
92
+ if await self._is_token_valid(token):
93
+ return token
94
+ self.delete_token()
95
+ return None
96
+
97
+ async def _is_token_valid(self, token: str) -> bool:
98
+ client = self._get_client()
99
+ try:
100
+ response = await client.get(
101
+ _VALIDATE_URL,
102
+ headers={
103
+ "Authorization": f"Bearer {token}",
104
+ "Accept": "application/vnd.github+json",
105
+ },
106
+ )
107
+ return response.is_success
108
+ except httpx.HTTPError:
109
+ return False
110
+
111
+ async def start_device_flow(self, open_browser: bool = True) -> DeviceFlowHandle:
112
+ client = self._get_client()
113
+ response = await client.post(
114
+ _DEVICE_CODE_URL,
115
+ data={"client_id": self._client_id, "scope": _SCOPES},
116
+ headers={"Accept": "application/json"},
117
+ )
118
+ if not response.is_success:
119
+ raise GitHubAuthError(f"Failed to initiate device flow: {response.text}")
120
+
121
+ data = response.json()
122
+
123
+ if open_browser:
124
+ webbrowser.open(data["verification_uri"])
125
+
126
+ return DeviceFlowHandle(
127
+ device_code=data["device_code"],
128
+ expires_in=data["expires_in"],
129
+ info=DeviceFlowInfo(data["user_code"], data["verification_uri"]),
130
+ )
131
+
132
+ async def wait_for_token(self, handle: DeviceFlowHandle) -> str:
133
+ client = self._get_client()
134
+ token = await self._poll_for_token(
135
+ client, handle.device_code, handle.expires_in, interval=1
136
+ )
137
+ self._save_token(token)
138
+ return token
139
+
140
+ def _save_token(self, token: str) -> None:
141
+ try:
142
+ keyring.set_password(_SERVICE_NAME, _KEYRING_USERNAME, token)
143
+ except keyring.errors.KeyringError as e:
144
+ raise GitHubAuthError(f"Failed to save token to keyring: {e}") from e
145
+
146
+ async def _poll_for_token(
147
+ self,
148
+ client: httpx.AsyncClient,
149
+ device_code: str,
150
+ expires_in: int,
151
+ interval: int,
152
+ ) -> str:
153
+ elapsed = 0.0
154
+ while elapsed < expires_in:
155
+ await asyncio.sleep(interval)
156
+ elapsed += interval
157
+
158
+ response = await client.post(
159
+ _TOKEN_URL,
160
+ data={
161
+ "client_id": self._client_id,
162
+ "device_code": device_code,
163
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
164
+ },
165
+ headers={"Accept": "application/json"},
166
+ )
167
+ result = response.json()
168
+
169
+ if "access_token" in result:
170
+ return result["access_token"]
171
+
172
+ error = result.get("error")
173
+ if error == "slow_down":
174
+ interval = result.get("interval", interval + 5)
175
+ elif error in {"expired_token", "access_denied"}:
176
+ raise GitHubAuthError(f"Authentication failed: {error}")
177
+
178
+ raise GitHubAuthError("Authentication timed out")
File without changes