bareagent-cli 0.1.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 (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,305 @@
1
+ """Apply an LSP ``WorkspaceEdit`` to disk.
2
+
3
+ A ``WorkspaceEdit`` is what a language server returns from
4
+ ``textDocument/rename``. It can carry edits in two shapes (LSP 3.x):
5
+
6
+ * ``changes`` — ``{uri: [TextEdit, ...]}`` (the legacy map form).
7
+ * ``documentChanges`` — an ordered list whose items are either
8
+ ``TextDocumentEdit`` (``{"textDocument": {"uri": ...}, "edits": [...]}``) or
9
+ *resource operations* (``CreateFile`` / ``RenameFile`` / ``DeleteFile``,
10
+ distinguished by a ``"kind"`` field). The semantic-rename MVP does **not**
11
+ perform file-level operations, so resource operations are collected into a
12
+ ``skipped`` list and surfaced to the caller rather than applied.
13
+
14
+ This module is intentionally free of any multilspy / LSP-client dependency so
15
+ it can be unit-tested with plain dicts. It only reads the file from disk,
16
+ applies ``TextEdit`` ranges, and writes back via
17
+ :func:`bareagent.core.fileutil.atomic_write_text`.
18
+
19
+ Coordinates inside a ``TextEdit`` ``range`` are 0-based ``(line, character)``
20
+ in LSP wire form. Multiple edits to the same file are applied **bottom-up**
21
+ (sorted by start position descending) so earlier edits never shift the
22
+ character offsets of later ones.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from bareagent.core.fileutil import atomic_write_text
31
+
32
+ from .coord import document_uri_to_path
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class WorkspaceEditResult:
37
+ """Outcome of applying a ``WorkspaceEdit``.
38
+
39
+ ``files`` maps an absolute (native) file path to the number of ``TextEdit``
40
+ entries applied to it. ``skipped`` holds human-readable descriptions of any
41
+ resource operations (CreateFile / RenameFile / DeleteFile) that the MVP did
42
+ not perform.
43
+ """
44
+
45
+ files: dict[str, int] = field(default_factory=dict)
46
+ skipped: list[str] = field(default_factory=list)
47
+
48
+ @property
49
+ def total_edits(self) -> int:
50
+ return sum(self.files.values())
51
+
52
+ @property
53
+ def changed_any(self) -> bool:
54
+ return bool(self.files)
55
+
56
+
57
+ def _iter_edit_groups(
58
+ workspace_edit: dict[str, Any],
59
+ skipped: list[str],
60
+ ) -> dict[str, list[dict[str, Any]]]:
61
+ """Normalize a WorkspaceEdit into ``{uri: [TextEdit, ...]}``.
62
+
63
+ A ``WorkspaceEdit`` may carry edits in ``changes`` *and/or*
64
+ ``documentChanges``. The LSP spec recommends a client that understands
65
+ ``documentChanges`` use it **exclusively** when present and ignore
66
+ ``changes`` entirely — ``changes`` is only the backward-compatibility
67
+ fallback for clients that don't. Merging both would apply the same edit
68
+ twice when a server emits both forms for one URI, and the bottom-up splice
69
+ would then corrupt the file. So we choose one form, never merge.
70
+
71
+ Resource operations inside ``documentChanges`` (items carrying a ``"kind"``
72
+ field) are recorded in ``skipped`` and not returned for application.
73
+ """
74
+ groups: dict[str, list[dict[str, Any]]] = {}
75
+
76
+ document_changes = workspace_edit.get("documentChanges")
77
+ if isinstance(document_changes, list):
78
+ # documentChanges present: parse it and ignore ``changes`` entirely.
79
+ for item in document_changes:
80
+ if not isinstance(item, dict):
81
+ continue
82
+ kind = item.get("kind")
83
+ if kind in ("create", "rename", "delete"):
84
+ # Resource operation — MVP does not do file-level renames.
85
+ skipped.append(_describe_resource_op(kind, item))
86
+ continue
87
+ text_document = item.get("textDocument")
88
+ uri = (
89
+ text_document.get("uri")
90
+ if isinstance(text_document, dict)
91
+ else None
92
+ )
93
+ edits = item.get("edits")
94
+ if not isinstance(uri, str) or not isinstance(edits, list):
95
+ continue
96
+ groups.setdefault(uri, []).extend(
97
+ edit for edit in edits if isinstance(edit, dict)
98
+ )
99
+ return groups
100
+
101
+ changes = workspace_edit.get("changes")
102
+ if isinstance(changes, dict):
103
+ for uri, edits in changes.items():
104
+ if not isinstance(uri, str) or not isinstance(edits, list):
105
+ continue
106
+ groups.setdefault(uri, []).extend(
107
+ edit for edit in edits if isinstance(edit, dict)
108
+ )
109
+
110
+ return groups
111
+
112
+
113
+ def _describe_resource_op(kind: str, item: dict[str, Any]) -> str:
114
+ """Best-effort one-line description of a skipped resource operation."""
115
+ if kind == "rename":
116
+ old = item.get("oldUri", "?")
117
+ new = item.get("newUri", "?")
118
+ return f"rename {old} -> {new}"
119
+ uri = item.get("uri", "?")
120
+ return f"{kind} {uri}"
121
+
122
+
123
+ def _edit_sort_key(edit: dict[str, Any]) -> tuple[int, int]:
124
+ """Sort key from a TextEdit's ``range.start`` (0-based line, character)."""
125
+ range_ = edit.get("range") or {}
126
+ start = range_.get("start") if isinstance(range_, dict) else None
127
+ if not isinstance(start, dict):
128
+ return (0, 0)
129
+ line = int(start.get("line", 0) or 0)
130
+ char = int(start.get("character", 0) or 0)
131
+ return (line, char)
132
+
133
+
134
+ def _utf16_units_to_py_col(line_text: str, utf16_char: int) -> int:
135
+ """Map a UTF-16 code-unit offset into a line to a Python str column index.
136
+
137
+ LSP ``Position.character`` is counted in **UTF-16 code units**, not Python
138
+ code points. multilspy 0.0.15 does not negotiate ``positionEncoding`` with
139
+ the server, so the default (UTF-16) is what every server uses. A non-BMP
140
+ character (``ord(ch) > 0xFFFF`` — emoji, astral plane) is a surrogate pair
141
+ occupying *two* UTF-16 units but a *single* Python str index. Treating the
142
+ character offset as a Python index therefore shifts every position after an
143
+ astral character left by one per astral char, silently corrupting the edit
144
+ range and the file.
145
+
146
+ We walk the line accumulating UTF-16 units until we reach ``utf16_char`` and
147
+ return the corresponding Python column. A ``utf16_char`` that lands in the
148
+ middle of a surrogate pair, or runs past the line's UTF-16 length, clamps to
149
+ the line end — matching the boundary-clamp safety of the offset path.
150
+
151
+ (The read-only ``lsp_definition`` / ``lsp_references`` coordinates in
152
+ ``coord.py`` pass ``character`` through unconverted; that only affects
153
+ display, so the small risk is out of scope here — only the write path can
154
+ corrupt a file.)
155
+ """
156
+ if utf16_char <= 0:
157
+ return 0
158
+ units = 0
159
+ for col, ch in enumerate(line_text):
160
+ if units >= utf16_char:
161
+ return col
162
+ units += 2 if ord(ch) > 0xFFFF else 1
163
+ return len(line_text)
164
+
165
+
166
+ def _offset_for_position(
167
+ line_starts: list[int],
168
+ lines: list[str],
169
+ line: int,
170
+ char: int,
171
+ text_len: int,
172
+ ) -> int:
173
+ """Convert a 0-based ``(line, character)`` to an absolute string offset.
174
+
175
+ ``line_starts[i]`` is the offset where line ``i`` begins and ``lines[i]`` is
176
+ that line's text (terminator stripped) used to translate the UTF-16
177
+ ``character`` into a Python column. Positions past the end of a line / file
178
+ are clamped to the file length so a malformed range from the server can
179
+ never raise — it just edits at the boundary.
180
+ """
181
+ if not line_starts:
182
+ return 0
183
+ if line < 0:
184
+ line = 0
185
+ if line >= len(line_starts):
186
+ return text_len
187
+ line_text = lines[line] if line < len(lines) else ""
188
+ py_col = _utf16_units_to_py_col(line_text, char)
189
+ return min(line_starts[line] + py_col, text_len)
190
+
191
+
192
+ def _build_line_starts(text: str) -> list[int]:
193
+ """Return the absolute offset at which each line begins.
194
+
195
+ Uses the same line model the LSP spec implies: a line ends at (and includes)
196
+ its terminator, and the next line starts immediately after. The final
197
+ sentinel lets a position on the last line resolve even when the file has no
198
+ trailing newline.
199
+ """
200
+ starts = [0]
201
+ for index, ch in enumerate(text):
202
+ if ch == "\n":
203
+ starts.append(index + 1)
204
+ return starts
205
+
206
+
207
+ def _build_lines(text: str) -> list[str]:
208
+ """Split ``text`` into per-line content for UTF-16 column translation.
209
+
210
+ Aligned with :func:`_build_line_starts`: one entry per line, split on
211
+ ``\\n``. A trailing ``\\r`` is kept (CRLF files) so UTF-16 unit counting
212
+ matches the bytes actually on the line; the splice still uses absolute
213
+ offsets, so the terminator is never disturbed.
214
+ """
215
+ return text.split("\n")
216
+
217
+
218
+ def apply_text_edits(text: str, edits: list[dict[str, Any]]) -> str:
219
+ """Apply a list of LSP ``TextEdit`` objects to ``text`` and return the result.
220
+
221
+ Edits are applied bottom-up (sorted by start position descending) so the
222
+ character offsets computed for earlier edits remain valid while later ones
223
+ are spliced in. This yields the same result as applying every edit against
224
+ the original document simultaneously, which is the LSP contract for a
225
+ single ``TextEdit[]`` (the spec forbids overlapping ranges).
226
+ """
227
+ line_starts = _build_line_starts(text)
228
+ lines = _build_lines(text)
229
+ text_len = len(text)
230
+ # Descending by start position: apply the last edit in the file first so
231
+ # splicing it never shifts offsets of edits that come earlier.
232
+ ordered = sorted(edits, key=_edit_sort_key, reverse=True)
233
+ result = text
234
+ for edit in ordered:
235
+ range_ = edit.get("range") or {}
236
+ start = range_.get("start") if isinstance(range_, dict) else None
237
+ end = range_.get("end") if isinstance(range_, dict) else None
238
+ new_text = edit.get("newText", "")
239
+ if not isinstance(start, dict) or not isinstance(end, dict):
240
+ continue
241
+ start_off = _offset_for_position(
242
+ line_starts,
243
+ lines,
244
+ int(start.get("line", 0) or 0),
245
+ int(start.get("character", 0) or 0),
246
+ text_len,
247
+ )
248
+ end_off = _offset_for_position(
249
+ line_starts,
250
+ lines,
251
+ int(end.get("line", 0) or 0),
252
+ int(end.get("character", 0) or 0),
253
+ text_len,
254
+ )
255
+ if end_off < start_off:
256
+ start_off, end_off = end_off, start_off
257
+ result = result[:start_off] + str(new_text) + result[end_off:]
258
+ return result
259
+
260
+
261
+ def apply_workspace_edit(workspace_edit: dict[str, Any]) -> WorkspaceEditResult:
262
+ """Apply a full ``WorkspaceEdit`` to disk and return a summary.
263
+
264
+ Parses both ``changes`` and ``documentChanges`` forms, groups the
265
+ ``TextEdit`` entries by URI, applies each group bottom-up, and writes the
266
+ result atomically. Resource operations (CreateFile / RenameFile /
267
+ DeleteFile) are skipped and reported. A URI that resolves to a non-``file:``
268
+ target, or whose file cannot be read, is skipped with a note rather than
269
+ raising — the caller turns an empty result into an explicit error.
270
+ """
271
+ result = WorkspaceEditResult()
272
+ groups = _iter_edit_groups(workspace_edit, result.skipped)
273
+
274
+ for uri, edits in groups.items():
275
+ if not edits:
276
+ continue
277
+ path = document_uri_to_path(uri)
278
+ if path.startswith("file:") or "://" in path:
279
+ # document_uri_to_path returns non-``file:`` URIs unchanged; we
280
+ # cannot write those (untitled / virtual docs).
281
+ result.skipped.append(f"unsupported document URI: {uri}")
282
+ continue
283
+ try:
284
+ with open(path, encoding="utf-8", newline="") as handle:
285
+ original = handle.read()
286
+ except OSError as exc:
287
+ result.skipped.append(f"could not read {path}: {exc}")
288
+ continue
289
+ updated = apply_text_edits(original, edits)
290
+ if updated != original:
291
+ atomic_write_text_path(path, updated)
292
+ result.files[path] = len(edits)
293
+
294
+ return result
295
+
296
+
297
+ def atomic_write_text_path(path: str, text: str) -> None:
298
+ """Thin shim so :func:`apply_workspace_edit` can write a ``str`` path.
299
+
300
+ :func:`bareagent.core.fileutil.atomic_write_text` takes a ``Path``; constructing
301
+ it here keeps the import surface of this module to one helper.
302
+ """
303
+ from pathlib import Path
304
+
305
+ atomic_write_text(Path(path), text)