bamboo-coding 0.1.0__tar.gz → 0.1.2__tar.gz

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 (82) hide show
  1. bamboo_coding-0.1.2/PKG-INFO +29 -0
  2. bamboo_coding-0.1.2/README.md +18 -0
  3. bamboo_coding-0.1.2/pyproject.toml +23 -0
  4. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/capabilities/repo.py +17 -2
  5. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/config.py +79 -12
  6. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/controller_client.py +31 -3
  7. {bamboo_coding-0.1.0 → bamboo_coding-0.1.2/src/bamboo_coding}/git_utils.py +145 -0
  8. bamboo_coding-0.1.2/src/bamboo_coding/main.py +774 -0
  9. {bamboo_coding-0.1.0/shared → bamboo_coding-0.1.2/src/bamboo_coding}/protocol.py +31 -0
  10. bamboo_coding-0.1.2/src/bamboo_coding/scrollback_buffer.py +53 -0
  11. {bamboo_coding-0.1.0/agent/bamboo_git_agent → bamboo_coding-0.1.2/src/bamboo_coding}/status.py +1 -1
  12. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/terminal_runtime.py +90 -4
  13. bamboo_coding-0.1.2/src/bamboo_coding.egg-info/PKG-INFO +29 -0
  14. bamboo_coding-0.1.2/src/bamboo_coding.egg-info/SOURCES.txt +20 -0
  15. bamboo_coding-0.1.2/src/bamboo_coding.egg-info/entry_points.txt +2 -0
  16. bamboo_coding-0.1.2/src/bamboo_coding.egg-info/requires.txt +4 -0
  17. bamboo_coding-0.1.2/src/bamboo_coding.egg-info/top_level.txt +1 -0
  18. bamboo_coding-0.1.0/PKG-INFO +0 -284
  19. bamboo_coding-0.1.0/README.md +0 -270
  20. bamboo_coding-0.1.0/agent/__init__.py +0 -0
  21. bamboo_coding-0.1.0/agent/bamboo_git_agent/__init__.py +0 -0
  22. bamboo_coding-0.1.0/agent/bamboo_git_agent/capabilities/__init__.py +0 -0
  23. bamboo_coding-0.1.0/agent/bamboo_git_agent/capabilities/repo.py +0 -189
  24. bamboo_coding-0.1.0/agent/bamboo_git_agent/config.py +0 -76
  25. bamboo_coding-0.1.0/agent/bamboo_git_agent/controller_client.py +0 -73
  26. bamboo_coding-0.1.0/agent/bamboo_git_agent/main.py +0 -164
  27. bamboo_coding-0.1.0/agent/bamboo_git_agent/protocol.py +0 -21
  28. bamboo_coding-0.1.0/api/__init__.py +0 -2
  29. bamboo_coding-0.1.0/api/commit.py +0 -82
  30. bamboo_coding-0.1.0/api/repository.py +0 -592
  31. bamboo_coding-0.1.0/bamboo_coding/__init__.py +0 -3
  32. bamboo_coding-0.1.0/bamboo_coding/client/journal.py +0 -1
  33. bamboo_coding-0.1.0/bamboo_coding/client/main.py +0 -425
  34. bamboo_coding-0.1.0/bamboo_coding/client/status.py +0 -59
  35. bamboo_coding-0.1.0/bamboo_coding/server/__init__.py +0 -3
  36. bamboo_coding-0.1.0/bamboo_coding/server/core/__init__.py +0 -1
  37. bamboo_coding-0.1.0/bamboo_coding/server/core/config.py +0 -1
  38. bamboo_coding-0.1.0/bamboo_coding/server/main.py +0 -39
  39. bamboo_coding-0.1.0/bamboo_coding/shared/__init__.py +0 -39
  40. bamboo_coding-0.1.0/bamboo_coding/shared/protocol.py +0 -1
  41. bamboo_coding-0.1.0/bamboo_coding.egg-info/PKG-INFO +0 -284
  42. bamboo_coding-0.1.0/bamboo_coding.egg-info/SOURCES.txt +0 -70
  43. bamboo_coding-0.1.0/bamboo_coding.egg-info/entry_points.txt +0 -3
  44. bamboo_coding-0.1.0/bamboo_coding.egg-info/requires.txt +0 -7
  45. bamboo_coding-0.1.0/bamboo_coding.egg-info/top_level.txt +0 -6
  46. bamboo_coding-0.1.0/controller/__init__.py +0 -0
  47. bamboo_coding-0.1.0/controller/app/__init__.py +0 -0
  48. bamboo_coding-0.1.0/controller/app/api/__init__.py +0 -0
  49. bamboo_coding-0.1.0/controller/app/api/agents/__init__.py +0 -3
  50. bamboo_coding-0.1.0/controller/app/api/agents/ws.py +0 -133
  51. bamboo_coding-0.1.0/controller/app/api/public/__init__.py +0 -11
  52. bamboo_coding-0.1.0/controller/app/api/public/repos.py +0 -207
  53. bamboo_coding-0.1.0/controller/app/api/public/terminals.py +0 -64
  54. bamboo_coding-0.1.0/controller/app/core/__init__.py +0 -0
  55. bamboo_coding-0.1.0/controller/app/core/config.py +0 -20
  56. bamboo_coding-0.1.0/controller/app/core/errors.py +0 -14
  57. bamboo_coding-0.1.0/controller/app/db/__init__.py +0 -0
  58. bamboo_coding-0.1.0/controller/app/db/models.py +0 -42
  59. bamboo_coding-0.1.0/controller/app/db/session.py +0 -74
  60. bamboo_coding-0.1.0/controller/app/main.py +0 -71
  61. bamboo_coding-0.1.0/controller/app/schemas/__init__.py +0 -23
  62. bamboo_coding-0.1.0/controller/app/schemas/agent_messages.py +0 -21
  63. bamboo_coding-0.1.0/controller/app/schemas/public.py +0 -3
  64. bamboo_coding-0.1.0/controller/app/services/__init__.py +0 -0
  65. bamboo_coding-0.1.0/controller/app/services/agents.py +0 -108
  66. bamboo_coding-0.1.0/controller/app/services/registrations.py +0 -17
  67. bamboo_coding-0.1.0/controller/app/services/repositories.py +0 -85
  68. bamboo_coding-0.1.0/controller/app/services/router.py +0 -140
  69. bamboo_coding-0.1.0/controller/app/services/tasks.py +0 -104
  70. bamboo_coding-0.1.0/controller/app/services/terminals.py +0 -148
  71. bamboo_coding-0.1.0/pyproject.toml +0 -29
  72. bamboo_coding-0.1.0/shared/__init__.py +0 -39
  73. bamboo_coding-0.1.0/tests/test_api.py +0 -140
  74. bamboo_coding-0.1.0/tests/test_container_contract.py +0 -7
  75. bamboo_coding-0.1.0/tests/test_frontend_contract.py +0 -3275
  76. bamboo_coding-0.1.0/tests/test_git_utils.py +0 -286
  77. bamboo_coding-0.1.0/tests/test_makefile.py +0 -62
  78. {bamboo_coding-0.1.0 → bamboo_coding-0.1.2}/setup.cfg +0 -0
  79. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/__init__.py +0 -0
  80. {bamboo_coding-0.1.0/bamboo_coding/client → bamboo_coding-0.1.2/src/bamboo_coding}/capabilities/__init__.py +0 -0
  81. {bamboo_coding-0.1.0/agent/bamboo_git_agent → bamboo_coding-0.1.2/src/bamboo_coding}/journal.py +0 -0
  82. {bamboo_coding-0.1.0 → bamboo_coding-0.1.2/src}/bamboo_coding.egg-info/dependency_links.txt +0 -0
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: bamboo-coding
3
+ Version: 0.1.2
4
+ Summary: Bamboo Coding outbound client
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: gitpython==3.1.40
8
+ Requires-Dist: websockets==13.1
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: bamboo-ssh
11
+
12
+ # bamboo-coding
13
+
14
+ Outbound client for the Bamboo Coding controller.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install bamboo-coding
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ bamboo-coding setup # configure default profile
26
+ bamboo-coding run # connect to the controller
27
+ ```
28
+
29
+ The client reads its configuration from `~/.bamboo-coding/bamboo-coding.toml`.
@@ -0,0 +1,18 @@
1
+ # bamboo-coding
2
+
3
+ Outbound client for the Bamboo Coding controller.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install bamboo-coding
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ bamboo-coding setup # configure default profile
15
+ bamboo-coding run # connect to the controller
16
+ ```
17
+
18
+ The client reads its configuration from `~/.bamboo-coding/bamboo-coding.toml`.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bamboo-coding"
7
+ version = "0.1.2"
8
+ description = "Bamboo Coding outbound client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "gitpython==3.1.40",
13
+ "websockets==13.1",
14
+ "pydantic>=2.0",
15
+ "bamboo-ssh",
16
+ ]
17
+
18
+ [project.scripts]
19
+ bamboo-coding = "bamboo_coding.main:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+ include = ["bamboo_coding*"]
@@ -3,8 +3,8 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Callable
5
5
 
6
- import git_utils
7
- from bamboo_coding.shared.protocol import TaskMessage
6
+ from .. import git_utils
7
+ from ..protocol import TaskMessage
8
8
 
9
9
 
10
10
  def _allowed_roots(repositories: dict[str, str] | list[str] | tuple[str, ...]) -> list[str]:
@@ -229,6 +229,19 @@ def _pull_remote(task: TaskMessage, repositories) -> dict:
229
229
  )
230
230
 
231
231
 
232
+ def _search(task: TaskMessage, repositories) -> dict:
233
+ repo_path = _resolve_repo_root(task, repositories)
234
+ query = str(task.params.get("query", ""))
235
+ return git_utils.search_repository(
236
+ repo_path,
237
+ query,
238
+ case_sensitive=bool(task.params.get("case_sensitive", False)),
239
+ whole_word=bool(task.params.get("whole_word", False)),
240
+ regex=bool(task.params.get("regex", False)),
241
+ max_matches=int(task.params.get("max_matches", 1000)),
242
+ )
243
+
244
+
232
245
  _HANDLERS: dict[tuple[str, str], Callable[[TaskMessage, dict[str, str] | list[str] | tuple[str, ...]], dict]] = {
233
246
  ("fs.tree", "get"): _fs_tree,
234
247
  ("fs.browse", "get"): _fs_browse,
@@ -276,4 +289,6 @@ _HANDLERS: dict[tuple[str, str], Callable[[TaskMessage, dict[str, str] | list[st
276
289
  ("git.branches.delete", "delete"): _delete_branch,
277
290
  ("git.branches.merge", "post"): _merge_branch,
278
291
  ("git.commit.create", "post"): _create_commit,
292
+ ("git.search", "post"): _search,
293
+ ("repo.search", "post"): _search,
279
294
  }
@@ -13,6 +13,8 @@ DEFAULT_CONFIG_DIRNAME = ".bamboo-coding"
13
13
  DEFAULT_CONFIG_FILENAME = "bamboo-coding.toml"
14
14
  DEFAULT_CONTROLLER_URL = "ws://127.0.0.1:8100/ws/agents"
15
15
  DEFAULT_CLIENT_ID = "bamboo-coding-client"
16
+ DEFAULT_PROFILE = "default"
17
+ SERVERS_DIRNAME = "servers"
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -32,6 +34,10 @@ class AgentConfig:
32
34
  token_path: Path
33
35
  journal_path: Path
34
36
  config_path: Path = field(default_factory=Path)
37
+ profile: str = DEFAULT_PROFILE
38
+ profile_exists: bool = True
39
+ legacy_client_section: bool = False
40
+ available_profiles: tuple[str, ...] = field(default_factory=tuple)
35
41
  max_concurrent_tasks: int = 1
36
42
  allowed_roots: tuple[str, ...] = field(default_factory=tuple)
37
43
  repositories: tuple[RepositoryConfig, ...] = field(default_factory=tuple)
@@ -107,7 +113,39 @@ def _load_legacy_token(default_token_path: Path) -> str:
107
113
  return token
108
114
 
109
115
 
110
- def load_config() -> AgentConfig:
116
+ def _resolve_profile_section(raw_config: dict, profile: str) -> tuple[dict | None, bool]:
117
+ """Return (profile_dict, is_legacy_client_section).
118
+
119
+ profile_dict is None if no matching profile was found.
120
+ """
121
+ servers = raw_config.get("servers")
122
+ if isinstance(servers, dict):
123
+ candidate = servers.get(profile)
124
+ if isinstance(candidate, dict):
125
+ return candidate, False
126
+ if profile == DEFAULT_PROFILE:
127
+ legacy = raw_config.get("client")
128
+ if isinstance(legacy, dict):
129
+ return legacy, True
130
+ return None, False
131
+
132
+
133
+ def _list_available_profiles(raw_config: dict) -> tuple[str, ...]:
134
+ names: list[str] = []
135
+ servers = raw_config.get("servers")
136
+ if isinstance(servers, dict):
137
+ names.extend(name for name in servers if isinstance(servers.get(name), dict))
138
+ if DEFAULT_PROFILE not in names and isinstance(raw_config.get("client"), dict):
139
+ names.insert(0, DEFAULT_PROFILE)
140
+ return tuple(names)
141
+
142
+
143
+ def _profile_storage_dir(storage_home: Path, profile: str) -> Path:
144
+ return storage_home / SERVERS_DIRNAME / profile
145
+
146
+
147
+ def load_config(profile: str | None = None) -> AgentConfig:
148
+ active_profile = profile or DEFAULT_PROFILE
111
149
  config_home = Path(_first_env("BAMBOO_CODING_HOME") or (Path.home() / DEFAULT_CONFIG_DIRNAME)).expanduser().resolve()
112
150
  config_path = _resolve_path(
113
151
  _first_env("BAMBOO_CODING_CONFIG_PATH"),
@@ -115,45 +153,70 @@ def load_config() -> AgentConfig:
115
153
  base_dir=config_home,
116
154
  )
117
155
  raw_config = _load_toml(config_path)
118
- client_config = raw_config.get("client") if isinstance(raw_config.get("client"), dict) else {}
119
- storage_config = raw_config.get("storage") if isinstance(raw_config.get("storage"), dict) else {}
156
+ profile_section, is_legacy_client = _resolve_profile_section(raw_config, active_profile)
157
+ available_profiles = _list_available_profiles(raw_config)
158
+ profile_exists = profile_section is not None
159
+ section = profile_section if isinstance(profile_section, dict) else {}
160
+
161
+ explicit_new_layout = profile_exists and not is_legacy_client
162
+ storage_config = (
163
+ raw_config.get("storage")
164
+ if not explicit_new_layout and isinstance(raw_config.get("storage"), dict)
165
+ else {}
166
+ )
120
167
 
121
168
  storage_home_value = _first_env("BAMBOO_CODING_HOME", "AGENT_HOME")
122
169
  storage_home = Path(storage_home_value).expanduser().resolve() if storage_home_value else config_home
123
170
 
171
+ if explicit_new_layout:
172
+ profile_dir = _profile_storage_dir(storage_home, active_profile)
173
+ default_token_path = profile_dir / "token.json"
174
+ default_journal_path = profile_dir / "journal.db"
175
+ elif active_profile == DEFAULT_PROFILE:
176
+ default_token_path = storage_home / "token.json"
177
+ default_journal_path = storage_home / "journal.db"
178
+ else:
179
+ profile_dir = _profile_storage_dir(storage_home, active_profile)
180
+ default_token_path = profile_dir / "token.json"
181
+ default_journal_path = profile_dir / "journal.db"
182
+
124
183
  controller_url = _first_env("BAMBOO_CODING_CONTROLLER_URL", "CONTROLLER_URL") or str(
125
- client_config.get("controller_url") or DEFAULT_CONTROLLER_URL
184
+ section.get("controller_url") or DEFAULT_CONTROLLER_URL
126
185
  )
127
186
  client_id = _first_env("BAMBOO_CODING_CLIENT_ID", "AGENT_CLIENT_ID") or str(
128
- client_config.get("client_id") or platform.node() or DEFAULT_CLIENT_ID
187
+ section.get("client_id") or platform.node() or DEFAULT_CLIENT_ID
129
188
  )
130
189
  hostname = _first_env("BAMBOO_CODING_HOSTNAME", "AGENT_HOSTNAME") or str(
131
- client_config.get("hostname") or platform.node() or client_id
190
+ section.get("hostname") or platform.node() or client_id
132
191
  )
133
- version = _first_env("BAMBOO_CODING_VERSION", "AGENT_VERSION") or str(client_config.get("version") or _default_version())
192
+ version = _first_env("BAMBOO_CODING_VERSION", "AGENT_VERSION") or str(section.get("version") or _default_version())
134
193
  allowed_roots = _coerce_allowed_roots(
135
194
  _first_env("BAMBOO_CODING_ALLOWED_ROOTS", "AGENT_ALLOWED_ROOTS", "AGENT_ROOT", "AGENT_REPO_PATH")
136
- or client_config.get("allowed_roots")
195
+ or section.get("allowed_roots")
137
196
  )
138
197
  max_concurrent_tasks = int(
139
198
  _first_env("BAMBOO_CODING_MAX_CONCURRENT_TASKS", "AGENT_MAX_CONCURRENT_TASKS")
140
- or client_config.get("max_concurrent_tasks")
199
+ or section.get("max_concurrent_tasks")
141
200
  or 1
142
201
  )
143
202
 
144
203
  token_path = _resolve_path(
145
204
  _first_env("BAMBOO_CODING_TOKEN_PATH", "AGENT_TOKEN_PATH") or storage_config.get("token_path"),
146
- default=storage_home / "token.json",
205
+ default=default_token_path,
147
206
  base_dir=config_path.parent,
148
207
  )
149
208
  journal_path = _resolve_path(
150
209
  _first_env("BAMBOO_CODING_JOURNAL_PATH", "AGENT_JOURNAL_PATH") or storage_config.get("journal_path"),
151
- default=storage_home / "journal.db",
210
+ default=default_journal_path,
152
211
  base_dir=config_path.parent,
153
212
  )
154
213
 
155
214
  token = _first_env("BAMBOO_CODING_TOKEN", "AGENT_TOKEN") or _load_stored_token(token_path)
156
- if not token and not _first_env("BAMBOO_CODING_TOKEN_PATH", "AGENT_TOKEN_PATH", "AGENT_HOME"):
215
+ if (
216
+ not token
217
+ and active_profile == DEFAULT_PROFILE
218
+ and not _first_env("BAMBOO_CODING_TOKEN_PATH", "AGENT_TOKEN_PATH", "AGENT_HOME")
219
+ ):
157
220
  token = _load_legacy_token(token_path)
158
221
 
159
222
  repositories = tuple(
@@ -173,6 +236,10 @@ def load_config() -> AgentConfig:
173
236
  token_path=token_path,
174
237
  journal_path=journal_path,
175
238
  config_path=config_path,
239
+ profile=active_profile,
240
+ profile_exists=profile_exists,
241
+ legacy_client_section=is_legacy_client,
242
+ available_profiles=available_profiles,
176
243
  max_concurrent_tasks=max_concurrent_tasks,
177
244
  allowed_roots=allowed_roots,
178
245
  repositories=repositories,
@@ -2,12 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
5
6
  from typing import Awaitable, Callable
6
7
 
7
8
  from websockets import connect
8
9
  from websockets.exceptions import ConnectionClosed
9
10
 
10
- from bamboo_coding.shared.protocol import (
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+ from .protocol import (
11
15
  AgentRegisterMessage,
12
16
  ClientStatusMessage,
13
17
  TaskEventMessage,
@@ -17,15 +21,27 @@ from bamboo_coding.shared.protocol import (
17
21
  TerminalCloseMessage,
18
22
  TerminalInputMessage,
19
23
  TerminalHeartbeatMessage,
24
+ TerminalKillMessage,
25
+ TerminalListRequest,
26
+ TerminalListResponse,
20
27
  TerminalOpenMessage,
21
28
  TerminalOutputMessage,
22
29
  TerminalResizeMessage,
30
+ TerminalSignalMessage,
23
31
  TerminalStatusMessage,
24
32
  TerminalClosedMessage,
25
33
  )
26
34
 
27
35
 
28
- TerminalControlMessage = TerminalOpenMessage | TerminalInputMessage | TerminalResizeMessage | TerminalCloseMessage
36
+ TerminalControlMessage = (
37
+ TerminalOpenMessage
38
+ | TerminalInputMessage
39
+ | TerminalResizeMessage
40
+ | TerminalCloseMessage
41
+ | TerminalSignalMessage
42
+ | TerminalKillMessage
43
+ | TerminalListRequest
44
+ )
29
45
 
30
46
 
31
47
  class ControllerConnection:
@@ -70,6 +86,9 @@ class ControllerConnection:
70
86
  async def send_terminal_closed(self, message: TerminalClosedMessage) -> None:
71
87
  await self._send(message.model_dump())
72
88
 
89
+ async def send_terminal_list_response(self, message: TerminalListResponse) -> None:
90
+ await self._send(message.model_dump())
91
+
73
92
  async def listen(
74
93
  self,
75
94
  on_task: Callable[[TaskMessage], Awaitable[None]],
@@ -91,6 +110,12 @@ class ControllerConnection:
91
110
  await on_terminal_message(TerminalResizeMessage(**data))
92
111
  elif on_terminal_message is not None and message_type == 'terminal_close':
93
112
  await on_terminal_message(TerminalCloseMessage(**data))
113
+ elif on_terminal_message is not None and message_type == 'terminal_signal':
114
+ await on_terminal_message(TerminalSignalMessage(**data))
115
+ elif on_terminal_message is not None and message_type == 'terminal_kill':
116
+ await on_terminal_message(TerminalKillMessage(**data))
117
+ elif on_terminal_message is not None and message_type == 'terminal_list':
118
+ await on_terminal_message(TerminalListRequest(**data))
94
119
  except ConnectionClosed:
95
120
  return
96
121
 
@@ -103,7 +128,10 @@ class ControllerConnection:
103
128
  if self.websocket is None:
104
129
  raise RuntimeError('connection not established')
105
130
  async with self._send_lock:
106
- await self.websocket.send(json.dumps(payload))
131
+ try:
132
+ await self.websocket.send(json.dumps(payload))
133
+ except ConnectionClosed:
134
+ LOGGER.debug('skipping outbound message; controller connection is closed')
107
135
 
108
136
  async def _receive(self) -> dict:
109
137
  if self.websocket is None:
@@ -14,7 +14,10 @@ Performance:
14
14
  - Diff context is limited to prevent memory issues with large files
15
15
  """
16
16
 
17
+ import json
17
18
  import os
19
+ import shutil
20
+ import subprocess
18
21
  import time
19
22
  from pathlib import Path
20
23
  from typing import Dict, List, Optional, Tuple, Any
@@ -2236,3 +2239,145 @@ def rename_workspace_path(path: str, new_path: str) -> Dict[str, Any]:
2236
2239
  'previous_path': str(src),
2237
2240
  'message': 'Path renamed successfully',
2238
2241
  }
2242
+
2243
+
2244
+ def search_repository(
2245
+ repo_path: str,
2246
+ query: str,
2247
+ *,
2248
+ case_sensitive: bool = False,
2249
+ whole_word: bool = False,
2250
+ regex: bool = False,
2251
+ max_matches: int = 1000,
2252
+ max_count_per_file: int = 100,
2253
+ timeout_seconds: float = 30.0,
2254
+ ) -> Dict[str, Any]:
2255
+ """Search the working tree for ``query`` using ripgrep.
2256
+
2257
+ Honors ``.gitignore``, ``.git/info/exclude`` and ``.ignore`` (ripgrep defaults).
2258
+ Returns matches relative to ``repo_path``.
2259
+
2260
+ Raises ``RuntimeError`` with a structured prefix on failure:
2261
+ - ``RIPGREP_NOT_INSTALLED: <hint>`` — ``rg`` binary missing on this machine.
2262
+ - ``INVALID_PATTERN: <stderr line>`` — ripgrep rejected the regex.
2263
+ """
2264
+ if not query:
2265
+ return {'matches': [], 'truncated': False, 'elapsed_ms': 0}
2266
+
2267
+ rg_path = shutil.which('rg')
2268
+ if rg_path is None:
2269
+ raise RuntimeError(
2270
+ 'RIPGREP_NOT_INSTALLED: ripgrep (rg) is not installed on the client machine. '
2271
+ 'Install it (e.g. `apt install ripgrep` or `brew install ripgrep`) and retry.'
2272
+ )
2273
+
2274
+ normalized_repo = _normalize_path(repo_path)
2275
+ if not Path(normalized_repo).is_dir():
2276
+ raise FileNotFoundError(f'Repository path not found: {repo_path}')
2277
+
2278
+ argv: List[str] = [
2279
+ rg_path,
2280
+ '--json',
2281
+ '--hidden',
2282
+ '--max-filesize=1M',
2283
+ '--crlf',
2284
+ f'--max-count={int(max_count_per_file)}',
2285
+ ]
2286
+ if not regex:
2287
+ argv.append('-F') # fixed string
2288
+ if not case_sensitive:
2289
+ argv.append('-i')
2290
+ if whole_word:
2291
+ argv.append('-w')
2292
+ argv.append('--')
2293
+ argv.append(query)
2294
+ argv.append(str(normalized_repo))
2295
+
2296
+ start = time.monotonic()
2297
+ try:
2298
+ proc = subprocess.run(
2299
+ argv,
2300
+ capture_output=True,
2301
+ text=True,
2302
+ timeout=timeout_seconds,
2303
+ check=False,
2304
+ )
2305
+ except subprocess.TimeoutExpired:
2306
+ elapsed_ms = int((time.monotonic() - start) * 1000)
2307
+ return {'matches': [], 'truncated': True, 'elapsed_ms': elapsed_ms}
2308
+
2309
+ elapsed_ms = int((time.monotonic() - start) * 1000)
2310
+
2311
+ # ripgrep exit codes: 0 = matches, 1 = no matches, 2 = error.
2312
+ if proc.returncode == 2:
2313
+ stderr = (proc.stderr or '').strip().splitlines()
2314
+ first = stderr[0] if stderr else 'ripgrep failed'
2315
+ raise RuntimeError(f'INVALID_PATTERN: {first}')
2316
+
2317
+ matches: List[Dict[str, Any]] = []
2318
+ truncated = False
2319
+ repo_root = str(Path(normalized_repo).resolve())
2320
+
2321
+ for raw_line in (proc.stdout or '').splitlines():
2322
+ if not raw_line:
2323
+ continue
2324
+ try:
2325
+ event = json.loads(raw_line)
2326
+ except json.JSONDecodeError:
2327
+ continue
2328
+ if event.get('type') != 'match':
2329
+ continue
2330
+ data = event.get('data') or {}
2331
+ path_obj = (data.get('path') or {})
2332
+ file_path = path_obj.get('text')
2333
+ if not file_path:
2334
+ # Binary or non-utf8 path — skip.
2335
+ continue
2336
+ try:
2337
+ rel_path = str(Path(file_path).resolve().relative_to(repo_root))
2338
+ except ValueError:
2339
+ rel_path = file_path
2340
+
2341
+ line_obj = (data.get('lines') or {})
2342
+ preview_text = line_obj.get('text', '')
2343
+ if preview_text.endswith('\n'):
2344
+ preview_text = preview_text[:-1]
2345
+ if preview_text.endswith('\r'):
2346
+ preview_text = preview_text[:-1]
2347
+ # Cap preview length to keep payloads small.
2348
+ if len(preview_text) > 500:
2349
+ preview_text = preview_text[:500] + '…'
2350
+
2351
+ line_number = int(data.get('line_number') or 0)
2352
+ submatches = data.get('submatches') or []
2353
+
2354
+ if not submatches:
2355
+ continue
2356
+
2357
+ for submatch in submatches:
2358
+ start_off = int(submatch.get('start') or 0)
2359
+ end_off = int(submatch.get('end') or start_off)
2360
+ # Clamp to preview length (we may have truncated above).
2361
+ if start_off > len(preview_text):
2362
+ start_off = len(preview_text)
2363
+ if end_off > len(preview_text):
2364
+ end_off = len(preview_text)
2365
+ matches.append({
2366
+ 'file': rel_path,
2367
+ 'line': line_number,
2368
+ 'col': start_off + 1,
2369
+ 'preview': preview_text,
2370
+ 'match_start': start_off,
2371
+ 'match_end': end_off,
2372
+ })
2373
+ if len(matches) >= max_matches:
2374
+ truncated = True
2375
+ break
2376
+ if truncated:
2377
+ break
2378
+
2379
+ return {
2380
+ 'matches': matches,
2381
+ 'truncated': truncated,
2382
+ 'elapsed_ms': elapsed_ms,
2383
+ }