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,196 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from git import InvalidGitRepositoryError, Repo
7
+ from git.exc import GitCommandError
8
+ from giturlparse import parse as parse_git_url
9
+
10
+ from vibe.core.teleport.errors import (
11
+ ServiceTeleportError,
12
+ ServiceTeleportNotSupportedError,
13
+ )
14
+ from vibe.core.utils import AsyncExecutor
15
+
16
+
17
+ @dataclass
18
+ class GitRepoInfo:
19
+ remote_url: str
20
+ owner: str
21
+ repo: str
22
+ branch: str | None
23
+ commit: str
24
+ diff: str
25
+
26
+
27
+ class GitRepository:
28
+ def __init__(self, workdir: Path | None = None) -> None:
29
+ self._workdir = workdir or Path.cwd()
30
+ self._repo: Repo | None = None
31
+ # For network I/O (fetch, push) and potentially slow git commands (diff, rev-list)
32
+ self._executor = AsyncExecutor(max_workers=2, timeout=60.0, name="git")
33
+
34
+ async def __aenter__(self) -> GitRepository:
35
+ return self
36
+
37
+ async def __aexit__(self, *_: object) -> None:
38
+ self._executor.shutdown(wait=False)
39
+
40
+ async def is_supported(self) -> bool:
41
+ try:
42
+ repo = self._repo_or_raise()
43
+ except ServiceTeleportNotSupportedError:
44
+ return False
45
+ return self._find_github_remote(repo) is not None
46
+
47
+ async def get_info(self) -> GitRepoInfo:
48
+ repo = self._repo_or_raise()
49
+
50
+ parsed = self._find_github_remote(repo)
51
+ if not parsed:
52
+ raise ServiceTeleportNotSupportedError(
53
+ "No GitHub remote found. Teleport only supports GitHub repositories."
54
+ )
55
+
56
+ try:
57
+ commit = repo.head.commit.hexsha
58
+ except (ValueError, TypeError) as e:
59
+ raise ServiceTeleportNotSupportedError(
60
+ "Could not determine current commit"
61
+ ) from e
62
+
63
+ if not commit:
64
+ raise ServiceTeleportNotSupportedError("Could not determine current commit")
65
+
66
+ owner, repo_name = parsed
67
+ branch = None if repo.head.is_detached else repo.active_branch.name
68
+ diff = await self._get_diff(repo)
69
+
70
+ return GitRepoInfo(
71
+ remote_url=self._to_https_url(owner, repo_name),
72
+ owner=owner,
73
+ repo=repo_name,
74
+ branch=branch,
75
+ commit=commit,
76
+ diff=diff,
77
+ )
78
+
79
+ async def is_commit_pushed(self, commit: str, remote: str = "origin") -> bool:
80
+ repo = self._repo_or_raise()
81
+ await self._fetch(repo, remote)
82
+ return await self._branch_contains(repo, commit, remote)
83
+
84
+ async def get_unpushed_commit_count(self, remote: str = "origin") -> int:
85
+ repo = self._repo_or_raise()
86
+
87
+ if repo.head.is_detached:
88
+ raise ServiceTeleportError(
89
+ "Cannot count unpushed commits: no current branch"
90
+ )
91
+ branch = repo.active_branch.name
92
+
93
+ await self._fetch(repo, remote)
94
+
95
+ result = await self._rev_list_count(repo, f"{remote}/{branch}..HEAD")
96
+ if result is not None:
97
+ return result
98
+
99
+ # Fallback: branch not pushed yet, count commits from default branch
100
+ default_branch = await self._get_remote_default_branch(repo, remote)
101
+ if default_branch:
102
+ result = await self._rev_list_count(repo, f"{default_branch}..HEAD")
103
+ if result is not None:
104
+ return result
105
+
106
+ raise ServiceTeleportError(f"Failed to count unpushed commits for {branch}")
107
+
108
+ async def push_current_branch(self, remote: str = "origin") -> bool:
109
+ repo = self._repo_or_raise()
110
+ if repo.head.is_detached:
111
+ return False
112
+ return await self._push(repo, repo.active_branch.name, remote)
113
+
114
+ def _repo_or_raise(self) -> Repo:
115
+ if self._repo is None:
116
+ try:
117
+ self._repo = Repo(self._workdir, search_parent_directories=True)
118
+ except InvalidGitRepositoryError as e:
119
+ raise ServiceTeleportNotSupportedError("Not a git repository") from e
120
+ return self._repo
121
+
122
+ def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:
123
+ for remote in repo.remotes:
124
+ for url in remote.urls:
125
+ if parsed := self._parse_github_url(url):
126
+ return parsed
127
+ return None
128
+
129
+ async def _fetch(self, repo: Repo, remote: str) -> None:
130
+ try:
131
+ await self._executor.run(lambda: repo.remote(remote).fetch())
132
+ except (TimeoutError, ValueError, GitCommandError):
133
+ pass
134
+
135
+ async def _get_diff(self, repo: Repo) -> str:
136
+ def get_full_diff() -> str:
137
+ # Mark untracked files as intent-to-add so they appear in diff
138
+ repo.git.add("-N", ".")
139
+ return repo.git.diff("HEAD", binary=True)
140
+
141
+ try:
142
+ return await self._executor.run(get_full_diff)
143
+ except (TimeoutError, GitCommandError):
144
+ return ""
145
+
146
+ async def _branch_contains(self, repo: Repo, commit: str, remote: str) -> bool:
147
+ try:
148
+ out = await self._executor.run(
149
+ lambda: repo.git.branch("-r", "--contains", commit)
150
+ )
151
+ return any(ln.strip().startswith(f"{remote}/") for ln in out.splitlines())
152
+ except (TimeoutError, GitCommandError):
153
+ return False
154
+
155
+ async def _rev_list_count(self, repo: Repo, ref_range: str) -> int | None:
156
+ try:
157
+ out = await self._executor.run(
158
+ lambda: repo.git.rev_list("--count", ref_range)
159
+ )
160
+ return int(out)
161
+ except (TimeoutError, GitCommandError, ValueError):
162
+ return None
163
+
164
+ async def _ref_exists(self, repo: Repo, ref: str) -> bool:
165
+ try:
166
+ await self._executor.run(lambda: repo.git.rev_parse("--verify", ref))
167
+ return True
168
+ except (TimeoutError, GitCommandError):
169
+ return False
170
+
171
+ async def _get_remote_default_branch(self, repo: Repo, remote: str) -> str | None:
172
+ try:
173
+ ref = repo.remotes[remote].refs.HEAD.reference.name
174
+ if await self._ref_exists(repo, ref):
175
+ return ref
176
+ except (KeyError, IndexError, TypeError, AttributeError):
177
+ pass
178
+ return None
179
+
180
+ async def _push(self, repo: Repo, branch: str, remote: str) -> bool:
181
+ try:
182
+ await self._executor.run(lambda: repo.remote(remote).push(branch))
183
+ return True
184
+ except (TimeoutError, ValueError, GitCommandError):
185
+ return False
186
+
187
+ @staticmethod
188
+ def _parse_github_url(url: str) -> tuple[str, str] | None:
189
+ p = parse_git_url(url)
190
+ if p.github and p.owner and p.repo:
191
+ return p.owner, p.repo
192
+ return None
193
+
194
+ @staticmethod
195
+ def _to_https_url(owner: str, repo: str) -> str:
196
+ return f"https://github.com/{owner}/{repo}.git"
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ import types
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from pydantic import BaseModel, Field
9
+
10
+ from vibe.core.auth import EncryptedPayload, encrypt
11
+ from vibe.core.teleport.errors import ServiceTeleportError
12
+
13
+
14
+ class GitRepoConfig(BaseModel):
15
+ url: str
16
+ branch: str | None = None
17
+ commit: str | None = None
18
+
19
+
20
+ class VibeSandboxConfig(BaseModel):
21
+ git_repo: GitRepoConfig | None = None
22
+
23
+
24
+ class VibeNewSandbox(BaseModel):
25
+ type: str = "new"
26
+ config: VibeSandboxConfig = Field(default_factory=VibeSandboxConfig)
27
+ teleported_diffs: bytes | None = None
28
+
29
+
30
+ class TeleportSession(BaseModel):
31
+ metadata: dict[str, Any] = Field(default_factory=dict)
32
+ messages: list[dict[str, Any]] = Field(default_factory=list)
33
+
34
+
35
+ class WorkflowParams(BaseModel):
36
+ prompt: str
37
+ sandbox: VibeNewSandbox
38
+ session: TeleportSession | None = None
39
+
40
+
41
+ class WorkflowExecuteResponse(BaseModel):
42
+ execution_id: str
43
+
44
+
45
+ class PublicKeyResult(BaseModel):
46
+ public_key: str
47
+
48
+
49
+ class QueryResponse(BaseModel):
50
+ result: PublicKeyResult
51
+
52
+
53
+ class CreateLeChatThreadInput(BaseModel):
54
+ encrypted_api_key: dict[str, str]
55
+ user_message: str
56
+ project_name: str | None = None
57
+
58
+
59
+ class CreateLeChatThreadOutput(BaseModel):
60
+ chat_url: str
61
+
62
+
63
+ class UpdateResponse(BaseModel):
64
+ result: CreateLeChatThreadOutput
65
+
66
+
67
+ class NuageClient:
68
+ def __init__(
69
+ self,
70
+ base_url: str,
71
+ api_key: str,
72
+ workflow_id: str,
73
+ *,
74
+ client: httpx.AsyncClient | None = None,
75
+ timeout: float = 60.0,
76
+ ) -> None:
77
+ self._base_url = base_url.rstrip("/")
78
+ self._api_key = api_key
79
+ self._workflow_id = workflow_id
80
+ self._client = client
81
+ self._owns_client = client is None
82
+ self._timeout = timeout
83
+
84
+ async def __aenter__(self) -> NuageClient:
85
+ if self._client is None:
86
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
87
+ return self
88
+
89
+ async def __aexit__(
90
+ self,
91
+ exc_type: type[BaseException] | None,
92
+ exc_val: BaseException | None,
93
+ exc_tb: types.TracebackType | None,
94
+ ) -> None:
95
+ if self._owns_client and self._client:
96
+ await self._client.aclose()
97
+ self._client = None
98
+
99
+ @property
100
+ def _http_client(self) -> httpx.AsyncClient:
101
+ if self._client is None:
102
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
103
+ self._owns_client = True
104
+ return self._client
105
+
106
+ def _headers(self) -> dict[str, str]:
107
+ return {
108
+ "Authorization": f"Bearer {self._api_key}",
109
+ "Content-Type": "application/json",
110
+ }
111
+
112
+ async def start_workflow(self, params: WorkflowParams) -> str:
113
+ response = await self._http_client.post(
114
+ f"{self._base_url}/v1/workflows/{self._workflow_id}/execute",
115
+ headers=self._headers(),
116
+ json={"input": params.model_dump(mode="json")},
117
+ )
118
+ if not response.is_success:
119
+ error_msg = f"Nuage workflow trigger failed: {response.text}"
120
+ # TODO(vibe-nuage): remove this once prod has shared vibe-nuage workers
121
+ if "Unauthorized" in response.text or "unauthorized" in response.text:
122
+ error_msg += (
123
+ "\n\nHint: This version uses Mistral staging environment. "
124
+ "Set STAGING_MISTRAL_API_KEY from https://console.globalaegis.net/"
125
+ )
126
+ raise ServiceTeleportError(error_msg)
127
+ result = WorkflowExecuteResponse.model_validate(response.json())
128
+ return result.execution_id
129
+
130
+ async def send_github_token(self, execution_id: str, token: str) -> None:
131
+ public_key_pem = await self._query_public_key(execution_id)
132
+ encrypted = encrypt(token, public_key_pem)
133
+ await self._signal_encrypted_token(execution_id, encrypted)
134
+
135
+ async def _query_public_key(self, execution_id: str) -> bytes:
136
+ response = await self._http_client.post(
137
+ f"{self._base_url}/v1/workflows/executions/{execution_id}/queries",
138
+ headers=self._headers(),
139
+ json={"name": "get_public_key", "input": {}},
140
+ )
141
+ if not response.is_success:
142
+ raise ServiceTeleportError(f"Failed to get public key: {response.text}")
143
+
144
+ result = QueryResponse.model_validate(response.json())
145
+ return result.result.public_key.encode("utf-8")
146
+
147
+ async def _signal_encrypted_token(
148
+ self, execution_id: str, encrypted: EncryptedPayload
149
+ ) -> None:
150
+ response = await self._http_client.post(
151
+ f"{self._base_url}/v1/workflows/executions/{execution_id}/signals",
152
+ headers=self._headers(),
153
+ json={"name": "github_token", "input": {"payload": asdict(encrypted)}},
154
+ )
155
+ if not response.is_success:
156
+ raise ServiceTeleportError(f"Failed to send GitHub token: {response.text}")
157
+
158
+ async def create_le_chat_thread(
159
+ self, execution_id: str, user_message: str, project_name: str | None = None
160
+ ) -> str:
161
+ public_key_pem = await self._query_public_key(execution_id)
162
+ encrypted = encrypt(self._api_key, public_key_pem)
163
+ input_data = CreateLeChatThreadInput(
164
+ encrypted_api_key={
165
+ k: v for k, v in asdict(encrypted).items() if v is not None
166
+ },
167
+ user_message=user_message,
168
+ project_name=project_name,
169
+ )
170
+ response = await self._http_client.post(
171
+ f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
172
+ headers=self._headers(),
173
+ json={"name": "create_le_chat_thread", "input": input_data.model_dump()},
174
+ )
175
+ if not response.is_success:
176
+ raise ServiceTeleportError(
177
+ f"Failed to create Le Chat thread: {response.text}"
178
+ )
179
+ result = UpdateResponse.model_validate(response.json())
180
+ return result.result.chat_url
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import AsyncGenerator
5
+ from pathlib import Path
6
+ import types
7
+
8
+ import httpx
9
+ import zstandard
10
+
11
+ from vibe.core.auth.github import GitHubAuthProvider
12
+ from vibe.core.session.session_logger import SessionLogger
13
+ from vibe.core.teleport.errors import ServiceTeleportError
14
+ from vibe.core.teleport.git import GitRepoInfo, GitRepository
15
+ from vibe.core.teleport.nuage import (
16
+ GitRepoConfig,
17
+ NuageClient,
18
+ TeleportSession,
19
+ VibeNewSandbox,
20
+ VibeSandboxConfig,
21
+ WorkflowParams,
22
+ )
23
+ from vibe.core.teleport.types import (
24
+ TeleportAuthCompleteEvent,
25
+ TeleportAuthRequiredEvent,
26
+ TeleportCheckingGitEvent,
27
+ TeleportCompleteEvent,
28
+ TeleportPushingEvent,
29
+ TeleportPushRequiredEvent,
30
+ TeleportPushResponseEvent,
31
+ TeleportSendEvent,
32
+ TeleportSendingGithubTokenEvent,
33
+ TeleportStartingWorkflowEvent,
34
+ TeleportYieldEvent,
35
+ )
36
+
37
+ # TODO(vibe-nuage): update URL once prod has shared vibe-nuage workers
38
+ _NUAGE_EXECUTION_URL_TEMPLATE = "https://console.globalaegis.net/build/workflows/{workflow_id}?tab=executions&executionId={execution_id}"
39
+ _DEFAULT_TELEPORT_PROMPT = "please continue where you left off"
40
+
41
+
42
+ class TeleportService:
43
+ def __init__(
44
+ self,
45
+ session_logger: SessionLogger,
46
+ nuage_base_url: str,
47
+ nuage_workflow_id: str,
48
+ nuage_api_key: str,
49
+ workdir: Path | None = None,
50
+ *,
51
+ client: httpx.AsyncClient | None = None,
52
+ timeout: float = 60.0,
53
+ ) -> None:
54
+ self._session_logger = session_logger
55
+ self._nuage_base_url = nuage_base_url
56
+ self._nuage_workflow_id = nuage_workflow_id
57
+ self._nuage_api_key = nuage_api_key
58
+ self._git = GitRepository(workdir)
59
+ self._client = client
60
+ self._owns_client = client is None
61
+ self._timeout = timeout
62
+ self._github_auth: GitHubAuthProvider | None = None
63
+ self._nuage: NuageClient | None = None
64
+
65
+ async def __aenter__(self) -> TeleportService:
66
+ if self._client is None:
67
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
68
+ self._github_auth = GitHubAuthProvider(client=self._client)
69
+ self._nuage = NuageClient(
70
+ self._nuage_base_url,
71
+ self._nuage_api_key,
72
+ self._nuage_workflow_id,
73
+ client=self._client,
74
+ )
75
+ await self._git.__aenter__()
76
+ return self
77
+
78
+ async def __aexit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc_val: BaseException | None,
82
+ exc_tb: types.TracebackType | None,
83
+ ) -> None:
84
+ await self._git.__aexit__(exc_type, exc_val, exc_tb)
85
+ if self._owns_client and self._client:
86
+ await self._client.aclose()
87
+ self._client = None
88
+
89
+ @property
90
+ def _http_client(self) -> httpx.AsyncClient:
91
+ if self._client is None:
92
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
93
+ self._owns_client = True
94
+ return self._client
95
+
96
+ @property
97
+ def _github_auth_provider(self) -> GitHubAuthProvider:
98
+ if self._github_auth is None:
99
+ self._github_auth = GitHubAuthProvider(client=self._http_client)
100
+ return self._github_auth
101
+
102
+ @property
103
+ def _nuage_client(self) -> NuageClient:
104
+ if self._nuage is None:
105
+ self._nuage = NuageClient(
106
+ self._nuage_base_url,
107
+ self._nuage_api_key,
108
+ self._nuage_workflow_id,
109
+ client=self._http_client,
110
+ )
111
+ return self._nuage
112
+
113
+ async def check_supported(self) -> None:
114
+ await self._git.get_info()
115
+
116
+ async def is_supported(self) -> bool:
117
+ return await self._git.is_supported()
118
+
119
+ async def execute(
120
+ self, prompt: str | None, session: TeleportSession
121
+ ) -> AsyncGenerator[TeleportYieldEvent, TeleportSendEvent]:
122
+ prompt = prompt or _DEFAULT_TELEPORT_PROMPT
123
+ self._validate_config()
124
+
125
+ git_info = await self._git.get_info()
126
+
127
+ yield TeleportCheckingGitEvent()
128
+ if not await self._git.is_commit_pushed(git_info.commit):
129
+ unpushed_count = await self._git.get_unpushed_commit_count()
130
+ response = yield TeleportPushRequiredEvent(
131
+ unpushed_count=max(1, unpushed_count)
132
+ )
133
+ if (
134
+ not isinstance(response, TeleportPushResponseEvent)
135
+ or not response.approved
136
+ ):
137
+ raise ServiceTeleportError("Teleport cancelled: commit not pushed.")
138
+
139
+ yield TeleportPushingEvent()
140
+ await self._push_or_fail()
141
+
142
+ github_token = await self._github_auth_provider.get_valid_token()
143
+
144
+ if not github_token:
145
+ handle = await self._github_auth_provider.start_device_flow(
146
+ open_browser=True
147
+ )
148
+ yield TeleportAuthRequiredEvent(
149
+ user_code=handle.info.user_code,
150
+ verification_uri=handle.info.verification_uri,
151
+ )
152
+ github_token = await self._github_auth_provider.wait_for_token(handle)
153
+ yield TeleportAuthCompleteEvent()
154
+
155
+ yield TeleportStartingWorkflowEvent()
156
+
157
+ execution_id = await self._nuage_client.start_workflow(
158
+ WorkflowParams(
159
+ prompt=prompt, sandbox=self._build_sandbox(git_info), session=session
160
+ )
161
+ )
162
+
163
+ yield TeleportSendingGithubTokenEvent()
164
+ await self._nuage_client.send_github_token(execution_id, github_token)
165
+
166
+ chat_url = _NUAGE_EXECUTION_URL_TEMPLATE.format(
167
+ workflow_id=self._nuage_workflow_id, execution_id=execution_id
168
+ )
169
+ # chat_url = await nuage.create_le_chat_thread(
170
+ # execution_id=execution_id, user_message=prompt
171
+ # )
172
+
173
+ yield TeleportCompleteEvent(url=chat_url)
174
+
175
+ async def _push_or_fail(self) -> None:
176
+ if not await self._git.push_current_branch():
177
+ raise ServiceTeleportError("Failed to push current branch to remote.")
178
+
179
+ def _validate_config(self) -> None:
180
+ # TODO(vibe-nuage): update error message once prod has shared vibe-nuage workers
181
+ if not self._nuage_api_key:
182
+ raise ServiceTeleportError(
183
+ "STAGING_MISTRAL_API_KEY not set. "
184
+ "Set it from https://console.globalaegis.net/ to use teleport."
185
+ )
186
+
187
+ def _build_sandbox(self, git_info: GitRepoInfo) -> VibeNewSandbox:
188
+ return VibeNewSandbox(
189
+ config=VibeSandboxConfig(
190
+ git_repo=GitRepoConfig(
191
+ url=git_info.remote_url,
192
+ branch=git_info.branch,
193
+ commit=git_info.commit,
194
+ )
195
+ ),
196
+ teleported_diffs=self._compress_diff(git_info.diff or ""),
197
+ )
198
+
199
+ def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None:
200
+ if not diff:
201
+ return None
202
+ compressed = zstandard.ZstdCompressor().compress(diff.encode("utf-8"))
203
+ encoded = base64.b64encode(compressed)
204
+ if len(encoded) > max_size:
205
+ raise ServiceTeleportError(
206
+ "Diff too large to teleport. Please commit and push your changes first."
207
+ )
208
+ return encoded
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.core.types import BaseEvent
4
+
5
+
6
+ class TeleportAuthRequiredEvent(BaseEvent):
7
+ user_code: str
8
+ verification_uri: str
9
+
10
+
11
+ class TeleportAuthCompleteEvent(BaseEvent):
12
+ pass
13
+
14
+
15
+ class TeleportStartingWorkflowEvent(BaseEvent):
16
+ pass
17
+
18
+
19
+ class TeleportCheckingGitEvent(BaseEvent):
20
+ pass
21
+
22
+
23
+ class TeleportPushRequiredEvent(BaseEvent):
24
+ unpushed_count: int = 1
25
+
26
+
27
+ class TeleportPushResponseEvent(BaseEvent):
28
+ approved: bool
29
+
30
+
31
+ class TeleportPushingEvent(BaseEvent):
32
+ pass
33
+
34
+
35
+ class TeleportSendingGithubTokenEvent(BaseEvent):
36
+ pass
37
+
38
+
39
+ class TeleportCompleteEvent(BaseEvent):
40
+ url: str
41
+
42
+
43
+ type TeleportYieldEvent = (
44
+ TeleportAuthRequiredEvent
45
+ | TeleportAuthCompleteEvent
46
+ | TeleportCheckingGitEvent
47
+ | TeleportPushRequiredEvent
48
+ | TeleportPushingEvent
49
+ | TeleportStartingWorkflowEvent
50
+ | TeleportSendingGithubTokenEvent
51
+ | TeleportCompleteEvent
52
+ )
53
+
54
+ type TeleportSendEvent = TeleportPushResponseEvent | None