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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- 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
|