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,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
|
+
}
|
vibe/core/auth/crypto.py
ADDED
|
@@ -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")
|
vibe/core/auth/github.py
ADDED
|
@@ -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
|