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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from queue import Empty
|
|
8
|
+
from socketserver import ThreadingMixIn
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
_VIEWER_HTML = Path(__file__).with_name("viewer.html")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DebugViewerHandler(BaseHTTPRequestHandler):
|
|
16
|
+
"""Serve the debug viewer SPA and read-only interaction APIs."""
|
|
17
|
+
|
|
18
|
+
server: DebugViewerServer
|
|
19
|
+
|
|
20
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
21
|
+
"""Silence the default HTTP request logging."""
|
|
22
|
+
|
|
23
|
+
def do_GET(self) -> None:
|
|
24
|
+
path = urlparse(self.path).path
|
|
25
|
+
|
|
26
|
+
if path == "/":
|
|
27
|
+
self._serve_html()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if path == "/api/sessions":
|
|
31
|
+
self._serve_json(self.server.logger.list_sessions())
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
session_prefix = "/api/sessions/"
|
|
35
|
+
if path.startswith(session_prefix):
|
|
36
|
+
session_id = unquote(path.removeprefix(session_prefix))
|
|
37
|
+
if session_id and "/" not in session_id:
|
|
38
|
+
try:
|
|
39
|
+
self._serve_json(self.server.logger.list_interactions(session_id))
|
|
40
|
+
except ValueError:
|
|
41
|
+
self._serve_error(404, "Not Found")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
interaction_prefix = "/api/interactions/"
|
|
45
|
+
if path.startswith(interaction_prefix):
|
|
46
|
+
remainder = path.removeprefix(interaction_prefix)
|
|
47
|
+
parts = [unquote(part) for part in remainder.split("/", maxsplit=1)]
|
|
48
|
+
if len(parts) == 2 and parts[0]:
|
|
49
|
+
session_id, seq_text = parts
|
|
50
|
+
try:
|
|
51
|
+
seq = int(seq_text)
|
|
52
|
+
except ValueError:
|
|
53
|
+
self._serve_error(400, "Invalid seq")
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
self._serve_json(
|
|
57
|
+
self.server.logger.get_interaction(session_id, seq)
|
|
58
|
+
)
|
|
59
|
+
except ValueError:
|
|
60
|
+
self._serve_error(404, "Not Found")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if path == "/api/events":
|
|
64
|
+
self._serve_sse()
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
self._serve_error(404, "Not Found")
|
|
68
|
+
|
|
69
|
+
def _serve_html(self) -> None:
|
|
70
|
+
try:
|
|
71
|
+
content = _VIEWER_HTML.read_bytes()
|
|
72
|
+
except OSError:
|
|
73
|
+
self._serve_error(500, "viewer.html not found")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
self.send_response(200)
|
|
77
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
78
|
+
self.send_header("Content-Length", str(len(content)))
|
|
79
|
+
self.end_headers()
|
|
80
|
+
self.wfile.write(content)
|
|
81
|
+
|
|
82
|
+
def _serve_json(self, data: Any) -> None:
|
|
83
|
+
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
|
84
|
+
self.send_response(200)
|
|
85
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
86
|
+
self.send_header("Content-Length", str(len(body)))
|
|
87
|
+
self.end_headers()
|
|
88
|
+
self.wfile.write(body)
|
|
89
|
+
|
|
90
|
+
def _serve_error(self, code: int, message: str) -> None:
|
|
91
|
+
body = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8")
|
|
92
|
+
self.send_response(code)
|
|
93
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
94
|
+
self.send_header("Content-Length", str(len(body)))
|
|
95
|
+
self.end_headers()
|
|
96
|
+
self.wfile.write(body)
|
|
97
|
+
|
|
98
|
+
def _serve_sse(self) -> None:
|
|
99
|
+
self.send_response(200)
|
|
100
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
101
|
+
self.send_header("Cache-Control", "no-cache")
|
|
102
|
+
self.send_header("Connection", "keep-alive")
|
|
103
|
+
self.end_headers()
|
|
104
|
+
|
|
105
|
+
subscribe = getattr(self.server.logger, "subscribe_events", None)
|
|
106
|
+
unsubscribe = getattr(self.server.logger, "unsubscribe_events", None)
|
|
107
|
+
event_queue: Any = (
|
|
108
|
+
subscribe() if callable(subscribe) else self.server.logger.event_queue
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
event = event_queue.get(timeout=30)
|
|
115
|
+
payload = json.dumps(event, ensure_ascii=False, default=str)
|
|
116
|
+
self.wfile.write(f"data: {payload}\n\n".encode())
|
|
117
|
+
except Empty:
|
|
118
|
+
self.wfile.write(b": heartbeat\n\n")
|
|
119
|
+
self.wfile.flush()
|
|
120
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
121
|
+
return
|
|
122
|
+
finally:
|
|
123
|
+
if callable(unsubscribe):
|
|
124
|
+
unsubscribe(event_queue)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DebugViewerServer(ThreadingMixIn, HTTPServer):
|
|
128
|
+
"""HTTP server that exposes an attached interaction logger."""
|
|
129
|
+
|
|
130
|
+
daemon_threads = True
|
|
131
|
+
allow_reuse_address = True
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
logger: Any,
|
|
136
|
+
port: int = 8321,
|
|
137
|
+
host: str = "127.0.0.1",
|
|
138
|
+
) -> None:
|
|
139
|
+
self.logger = logger
|
|
140
|
+
super().__init__((host, port), DebugViewerHandler)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def start_viewer(
|
|
144
|
+
logger: Any,
|
|
145
|
+
port: int = 8321,
|
|
146
|
+
host: str = "127.0.0.1",
|
|
147
|
+
) -> tuple[DebugViewerServer, threading.Thread]:
|
|
148
|
+
"""Start the debug viewer on a daemon thread."""
|
|
149
|
+
|
|
150
|
+
server = DebugViewerServer(logger, port=port, host=host)
|
|
151
|
+
thread = threading.Thread(
|
|
152
|
+
target=server.serve_forever,
|
|
153
|
+
daemon=True,
|
|
154
|
+
name="debug-viewer",
|
|
155
|
+
)
|
|
156
|
+
thread.start()
|
|
157
|
+
return server, thread
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""User-defined hooks fired around tool execution in the main agent loop.
|
|
2
|
+
|
|
3
|
+
Users declare ``[[hooks]]`` in ``config.toml`` to run custom shell commands
|
|
4
|
+
before and after a tool call:
|
|
5
|
+
|
|
6
|
+
- ``PreToolUse`` fires after the permission check passes but before the handler
|
|
7
|
+
runs. An exit code of 2 intercepts the call (the handler is skipped and the
|
|
8
|
+
hook's stderr is fed back to the LLM as an error result).
|
|
9
|
+
- ``PostToolUse`` fires after the handler returns successfully, for side effects
|
|
10
|
+
(e.g. ``ruff format`` after ``write_file``). Its exit code is ignored.
|
|
11
|
+
|
|
12
|
+
The permission guard remains the security boundary; hooks are a user-configured
|
|
13
|
+
convenience layer (trust-the-config), and they only fire in the main loop —
|
|
14
|
+
sub-agents never run hooks (isolation). Failures are fail-open.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .config import HookEntry, HooksConfig, parse_hooks_config
|
|
20
|
+
from .engine import HookEngine, HookOutcome
|
|
21
|
+
from .errors import HookConfigError
|
|
22
|
+
from .events import HookEvent
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"HookConfigError",
|
|
26
|
+
"HookEngine",
|
|
27
|
+
"HookEntry",
|
|
28
|
+
"HookEvent",
|
|
29
|
+
"HookOutcome",
|
|
30
|
+
"HooksConfig",
|
|
31
|
+
"parse_hooks_config",
|
|
32
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Hook configuration parsing.
|
|
2
|
+
|
|
3
|
+
Reads a ``[[hooks]]`` array of tables from a TOML-derived dict and returns a
|
|
4
|
+
typed :class:`HooksConfig`. Each entry binds a shell ``command`` to a
|
|
5
|
+
:class:`~bareagent.hooks.events.HookEvent` and an optional precise ``tool`` name
|
|
6
|
+
(omitted = matches every tool).
|
|
7
|
+
|
|
8
|
+
Graceful degradation policy (mirrors MCP/LSP): a structurally broken document
|
|
9
|
+
(non-list ``hooks``, non-table entry) raises :class:`HookConfigError` so
|
|
10
|
+
``main.py`` can warn and fall back to an empty config. A single *malformed*
|
|
11
|
+
entry (unknown event, blank command) is skipped and recorded in
|
|
12
|
+
:attr:`HooksConfig.skipped`, rather than nuking the whole config — one bad line
|
|
13
|
+
should not disable a user's other working hooks.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .errors import HookConfigError
|
|
22
|
+
from .events import HookEvent
|
|
23
|
+
|
|
24
|
+
_VALID_EVENTS = frozenset(event.value for event in HookEvent)
|
|
25
|
+
_DEFAULT_TIMEOUT = 30
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class HookEntry:
|
|
30
|
+
"""One ``[[hooks]]`` entry: when to fire and what to run."""
|
|
31
|
+
|
|
32
|
+
event: str
|
|
33
|
+
command: str
|
|
34
|
+
# None => match every tool for this event.
|
|
35
|
+
tool: str | None = None
|
|
36
|
+
timeout: int = _DEFAULT_TIMEOUT
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class HooksConfig:
|
|
41
|
+
"""Top-level hooks configuration."""
|
|
42
|
+
|
|
43
|
+
entries: list[HookEntry] = field(default_factory=list)
|
|
44
|
+
# Human-readable reasons for entries that were dropped during parsing.
|
|
45
|
+
skipped: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
def matching(self, event: str, tool_name: str) -> list[HookEntry]:
|
|
48
|
+
"""Return entries for *event* whose ``tool`` is None or equals *tool_name*.
|
|
49
|
+
|
|
50
|
+
Order is preserved (config declaration order) so hooks fire predictably.
|
|
51
|
+
"""
|
|
52
|
+
return [
|
|
53
|
+
entry
|
|
54
|
+
for entry in self.entries
|
|
55
|
+
if entry.event == event and (entry.tool is None or entry.tool == tool_name)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_hooks_config(raw: dict[str, Any]) -> HooksConfig:
|
|
60
|
+
"""Parse a TOML-derived dict into :class:`HooksConfig`.
|
|
61
|
+
|
|
62
|
+
Accepts either the full document (where ``hooks`` is a key holding the
|
|
63
|
+
array of tables) or a bare ``{"hooks": [...]}`` wrapper. Unknown keys on an
|
|
64
|
+
entry are ignored to stay forward-compatible.
|
|
65
|
+
|
|
66
|
+
Raises :class:`HookConfigError` only for structural failures (the document
|
|
67
|
+
or the ``hooks`` value has the wrong shape). Individual malformed entries
|
|
68
|
+
are skipped and recorded in :attr:`HooksConfig.skipped`.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(raw, dict):
|
|
71
|
+
raise HookConfigError(f"hooks config must be a table, got {type(raw).__name__}")
|
|
72
|
+
|
|
73
|
+
entries_raw = raw.get("hooks", [])
|
|
74
|
+
if not isinstance(entries_raw, list):
|
|
75
|
+
raise HookConfigError("'hooks' must be an array of tables")
|
|
76
|
+
|
|
77
|
+
config = HooksConfig()
|
|
78
|
+
for index, entry in enumerate(entries_raw):
|
|
79
|
+
if not isinstance(entry, dict):
|
|
80
|
+
config.skipped.append(f"hooks[{index}] is not a table; skipped")
|
|
81
|
+
continue
|
|
82
|
+
parsed = _parse_entry(entry, index, config.skipped)
|
|
83
|
+
if parsed is not None:
|
|
84
|
+
config.entries.append(parsed)
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_entry(entry: dict[str, Any], index: int, skipped: list[str]) -> HookEntry | None:
|
|
89
|
+
event = entry.get("event")
|
|
90
|
+
if event not in _VALID_EVENTS:
|
|
91
|
+
skipped.append(
|
|
92
|
+
f"hooks[{index}].event must be one of {sorted(_VALID_EVENTS)}, got {event!r}; skipped"
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
command = entry.get("command")
|
|
97
|
+
if not isinstance(command, str) or not command.strip():
|
|
98
|
+
skipped.append(
|
|
99
|
+
f"hooks[{index}].command is required and must be a non-empty string; skipped"
|
|
100
|
+
)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
tool = entry.get("tool")
|
|
104
|
+
if tool is not None and (not isinstance(tool, str) or not tool):
|
|
105
|
+
skipped.append(f"hooks[{index}].tool must be a non-empty string if provided; skipped")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
timeout = entry.get("timeout", _DEFAULT_TIMEOUT)
|
|
109
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
|
110
|
+
skipped.append(f"hooks[{index}].timeout must be a positive integer; skipped")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
return HookEntry(
|
|
114
|
+
event=str(event),
|
|
115
|
+
command=command,
|
|
116
|
+
tool=tool,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Hook execution engine.
|
|
2
|
+
|
|
3
|
+
Matches configured hooks against a tool call and runs them as cross-platform
|
|
4
|
+
subprocesses, passing the call context as a single JSON object on stdin. The
|
|
5
|
+
control protocol is exit-code based (PRD D2):
|
|
6
|
+
|
|
7
|
+
- ``PreToolUse`` exit 2 -> intercept: the handler is skipped and the hook's
|
|
8
|
+
stderr is returned to the LLM as an error result.
|
|
9
|
+
- exit 0 -> allow.
|
|
10
|
+
- any other non-zero exit -> non-blocking warning, then allow.
|
|
11
|
+
|
|
12
|
+
Failures are fail-open (PRD D3): a spawn error or timeout warns and allows; it
|
|
13
|
+
never blocks the tool or crashes the loop. ``PostToolUse`` hooks run side
|
|
14
|
+
effects only — their exit code never changes the tool result.
|
|
15
|
+
|
|
16
|
+
This module must NOT import :mod:`bareagent.core.loop` — the engine is *called by* the
|
|
17
|
+
loop, and the reverse dependency would create an import cycle.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from bareagent.core.fileutil import stringify
|
|
29
|
+
from bareagent.ui.protocol import UIProtocol
|
|
30
|
+
|
|
31
|
+
from .config import HookEntry, HooksConfig
|
|
32
|
+
from .events import HookEvent
|
|
33
|
+
|
|
34
|
+
# Exit code reserved by the Claude-Code-aligned protocol for "intercept".
|
|
35
|
+
_BLOCK_EXIT_CODE = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class HookOutcome:
|
|
40
|
+
"""Result of running the PreToolUse hooks for one tool call."""
|
|
41
|
+
|
|
42
|
+
block: bool = False
|
|
43
|
+
reason: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HookEngine:
|
|
47
|
+
"""Runs PreToolUse / PostToolUse hooks for the main agent loop."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: HooksConfig, *, console: UIProtocol | None = None) -> None:
|
|
50
|
+
self._config = config
|
|
51
|
+
self._console = console
|
|
52
|
+
|
|
53
|
+
def run_pre_tool_use(
|
|
54
|
+
self,
|
|
55
|
+
tool_name: str,
|
|
56
|
+
tool_input: dict[str, Any],
|
|
57
|
+
*,
|
|
58
|
+
session_id: str,
|
|
59
|
+
cwd: str,
|
|
60
|
+
) -> HookOutcome:
|
|
61
|
+
"""Run matching PreToolUse hooks. First exit-2 hook intercepts the call."""
|
|
62
|
+
entries = self._config.matching(HookEvent.PRE_TOOL_USE.value, tool_name)
|
|
63
|
+
if not entries:
|
|
64
|
+
return HookOutcome()
|
|
65
|
+
|
|
66
|
+
payload = {
|
|
67
|
+
"event": HookEvent.PRE_TOOL_USE.value,
|
|
68
|
+
"tool_name": tool_name,
|
|
69
|
+
"tool_input": tool_input,
|
|
70
|
+
"session_id": session_id,
|
|
71
|
+
"cwd": cwd,
|
|
72
|
+
}
|
|
73
|
+
for entry in entries:
|
|
74
|
+
result = self._run_one(entry, payload)
|
|
75
|
+
if result is None:
|
|
76
|
+
# Spawn failure / timeout -> fail-open, already warned.
|
|
77
|
+
continue
|
|
78
|
+
if result.returncode == _BLOCK_EXIT_CODE:
|
|
79
|
+
reason = (result.stderr or "").strip()
|
|
80
|
+
return HookOutcome(
|
|
81
|
+
block=True,
|
|
82
|
+
reason=reason or f"Blocked by PreToolUse hook for {tool_name}.",
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
self._warn_non_zero(entry, tool_name, result)
|
|
86
|
+
return HookOutcome()
|
|
87
|
+
|
|
88
|
+
def run_post_tool_use(
|
|
89
|
+
self,
|
|
90
|
+
tool_name: str,
|
|
91
|
+
tool_input: dict[str, Any],
|
|
92
|
+
tool_output: Any,
|
|
93
|
+
*,
|
|
94
|
+
is_error: bool,
|
|
95
|
+
session_id: str,
|
|
96
|
+
cwd: str,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Run matching PostToolUse hooks for side effects. Exit codes don't matter."""
|
|
99
|
+
entries = self._config.matching(HookEvent.POST_TOOL_USE.value, tool_name)
|
|
100
|
+
if not entries:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
payload = {
|
|
104
|
+
"event": HookEvent.POST_TOOL_USE.value,
|
|
105
|
+
"tool_name": tool_name,
|
|
106
|
+
"tool_input": tool_input,
|
|
107
|
+
"session_id": session_id,
|
|
108
|
+
"cwd": cwd,
|
|
109
|
+
"tool_output": stringify(tool_output),
|
|
110
|
+
"is_error": is_error,
|
|
111
|
+
}
|
|
112
|
+
for entry in entries:
|
|
113
|
+
result = self._run_one(entry, payload)
|
|
114
|
+
if result is None:
|
|
115
|
+
continue
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
self._warn_non_zero(entry, tool_name, result)
|
|
118
|
+
|
|
119
|
+
def _run_one(
|
|
120
|
+
self, entry: HookEntry, payload: dict[str, Any]
|
|
121
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
122
|
+
"""Spawn the hook command, feeding *payload* as JSON on stdin.
|
|
123
|
+
|
|
124
|
+
Returns the completed process, or ``None`` when the hook failed to run
|
|
125
|
+
at all (timeout / spawn error) — both fail-open cases (PRD D3).
|
|
126
|
+
"""
|
|
127
|
+
argv = _build_argv(entry.command)
|
|
128
|
+
stdin_json = json.dumps(payload, ensure_ascii=False)
|
|
129
|
+
try:
|
|
130
|
+
return subprocess.run(
|
|
131
|
+
argv,
|
|
132
|
+
input=stdin_json,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
timeout=entry.timeout,
|
|
135
|
+
check=False,
|
|
136
|
+
text=True,
|
|
137
|
+
encoding="utf-8",
|
|
138
|
+
errors="replace",
|
|
139
|
+
)
|
|
140
|
+
except subprocess.TimeoutExpired:
|
|
141
|
+
self._warn(
|
|
142
|
+
f"{entry.event} hook timed out after {entry.timeout}s "
|
|
143
|
+
f"(command: {entry.command!r}); allowing tool."
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
except (OSError, ValueError) as exc:
|
|
147
|
+
self._warn(
|
|
148
|
+
f"{entry.event} hook failed to start ({type(exc).__name__}: {exc}); allowing tool."
|
|
149
|
+
)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _warn_non_zero(
|
|
153
|
+
self,
|
|
154
|
+
entry: HookEntry,
|
|
155
|
+
tool_name: str,
|
|
156
|
+
result: subprocess.CompletedProcess[str],
|
|
157
|
+
) -> None:
|
|
158
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
159
|
+
message = (
|
|
160
|
+
f"{entry.event} hook for {tool_name} exited with code "
|
|
161
|
+
f"{result.returncode} (non-blocking)."
|
|
162
|
+
)
|
|
163
|
+
if detail:
|
|
164
|
+
message = f"{message} {detail}"
|
|
165
|
+
self._warn(message)
|
|
166
|
+
|
|
167
|
+
def _warn(self, message: str) -> None:
|
|
168
|
+
if self._console is not None:
|
|
169
|
+
self._console.print_status(message)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _build_argv(command: str) -> list[str]:
|
|
173
|
+
"""Build the cross-platform shell argv for *command*.
|
|
174
|
+
|
|
175
|
+
Mirrors :func:`bareagent.core.handlers.bash.run_bash`: Windows PowerShell with the
|
|
176
|
+
output encoding forced to UTF-8 so non-ASCII stdout/stderr round-trips, and
|
|
177
|
+
``bash -lc`` elsewhere.
|
|
178
|
+
|
|
179
|
+
Unlike ``run_bash`` (which only reads ``returncode`` for a status message),
|
|
180
|
+
the hook control protocol *depends* on the child's exit code, so the
|
|
181
|
+
PowerShell wrapper appends ``; exit $LASTEXITCODE``. Without it,
|
|
182
|
+
``powershell -Command`` returns its own parse/host exit status (typically 0
|
|
183
|
+
or 1) rather than the command's exit code — which would silently break the
|
|
184
|
+
exit-2 intercept contract.
|
|
185
|
+
"""
|
|
186
|
+
if os.name == "nt":
|
|
187
|
+
windows_prefix = (
|
|
188
|
+
"try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; "
|
|
189
|
+
)
|
|
190
|
+
windows_suffix = "; exit $LASTEXITCODE"
|
|
191
|
+
return [
|
|
192
|
+
"powershell",
|
|
193
|
+
"-NoProfile",
|
|
194
|
+
"-Command",
|
|
195
|
+
windows_prefix + command + windows_suffix,
|
|
196
|
+
]
|
|
197
|
+
return ["bash", "-lc", command]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Hook error hierarchy.
|
|
2
|
+
|
|
3
|
+
Only ``HookConfigError`` is raised — at parse time, so ``main.py`` can catch it
|
|
4
|
+
and degrade gracefully (warn + run with an empty :class:`HooksConfig`), mirroring
|
|
5
|
+
the MCP/LSP config-failure fallbacks. Hook *runtime* failures (spawn error,
|
|
6
|
+
timeout, non-zero exit) are never exceptions: they are fail-open per PRD D3 and
|
|
7
|
+
surface as console warnings instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookConfigError(Exception):
|
|
14
|
+
"""Raised when the ``[[hooks]]`` configuration is structurally invalid."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Hook event identifiers.
|
|
2
|
+
|
|
3
|
+
The MVP supports exactly two events (see PRD decision D1):
|
|
4
|
+
|
|
5
|
+
- ``PreToolUse`` — fired after the permission check passes but *before* the
|
|
6
|
+
tool handler runs. A hook can intercept the tool (exit code 2).
|
|
7
|
+
- ``PostToolUse`` — fired after the handler returns successfully, before the
|
|
8
|
+
result is wrapped back to the LLM. A hook may run side effects; its exit code
|
|
9
|
+
never changes the tool result.
|
|
10
|
+
|
|
11
|
+
Values match Claude Code's hook event names so that user mental models and
|
|
12
|
+
config snippets carry over.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookEvent(StrEnum):
|
|
21
|
+
PRE_TOOL_USE = "PreToolUse"
|
|
22
|
+
POST_TOOL_USE = "PostToolUse"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""LSP (Language Server Protocol) client subpackage.
|
|
2
|
+
|
|
3
|
+
PR1 (this child) delivers the multi-language manager, four Tier-1 tools
|
|
4
|
+
(``lsp_outline`` / ``lsp_definition`` / ``lsp_references`` /
|
|
5
|
+
``lsp_diagnostics``), config parsing, and agent-type integration. UX (REPL
|
|
6
|
+
commands), atexit cleanup, hybrid auto-diagnostics, and the real
|
|
7
|
+
``[lsp]`` extra E2E test land in the sibling child task.
|
|
8
|
+
|
|
9
|
+
``multilspy`` is an **optional dependency**. Importing this package never
|
|
10
|
+
raises when the extra is missing — the boolean ``MULTILSPY_AVAILABLE`` lets
|
|
11
|
+
callers feature-detect, and :class:`LanguageServerManager` degrades to a
|
|
12
|
+
no-op when the underlying library is unavailable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .config import LSPConfig, LSPServerConfig, parse_lsp_config
|
|
18
|
+
from .errors import LSPCallError, LSPError, LSPHandshakeError
|
|
19
|
+
from .manager import LanguageServerManager, ServerStatus
|
|
20
|
+
from .tools import (
|
|
21
|
+
LSP_TOOL_NAMES,
|
|
22
|
+
LSP_TOOL_SCHEMAS,
|
|
23
|
+
SEMANTIC_RENAME_TOOL_NAME,
|
|
24
|
+
SEMANTIC_RENAME_TOOL_SCHEMA,
|
|
25
|
+
build_lsp_tools,
|
|
26
|
+
)
|
|
27
|
+
from .workspace_edit import apply_workspace_edit
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_multilspy() -> bool:
|
|
31
|
+
"""Return True iff ``import multilspy`` succeeds at package-import time.
|
|
32
|
+
|
|
33
|
+
The check is done eagerly here so callers can branch on the boolean
|
|
34
|
+
without paying an import every call. Errors other than ``ImportError``
|
|
35
|
+
(rare, e.g. a broken install) are treated as "not available".
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
import multilspy # noqa: F401 # type: ignore
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
MULTILSPY_AVAILABLE: bool = _detect_multilspy()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"LSPCallError",
|
|
49
|
+
"LSPConfig",
|
|
50
|
+
"LSPError",
|
|
51
|
+
"LSPHandshakeError",
|
|
52
|
+
"LSPServerConfig",
|
|
53
|
+
"LSP_TOOL_NAMES",
|
|
54
|
+
"LSP_TOOL_SCHEMAS",
|
|
55
|
+
"SEMANTIC_RENAME_TOOL_NAME",
|
|
56
|
+
"SEMANTIC_RENAME_TOOL_SCHEMA",
|
|
57
|
+
"LanguageServerManager",
|
|
58
|
+
"MULTILSPY_AVAILABLE",
|
|
59
|
+
"ServerStatus",
|
|
60
|
+
"apply_workspace_edit",
|
|
61
|
+
"build_lsp_tools",
|
|
62
|
+
"parse_lsp_config",
|
|
63
|
+
]
|