axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/config.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Configuration management with 3-layer merge and full MCP server config.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/config.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigSource(enum.Enum):
|
|
17
|
+
USER = "user"
|
|
18
|
+
PROJECT = "project"
|
|
19
|
+
LOCAL = "local"
|
|
20
|
+
ENVIRONMENT = "environment"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(Exception):
|
|
24
|
+
"""Configuration loading error."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConfigEntry:
|
|
29
|
+
"""A single loaded configuration entry."""
|
|
30
|
+
|
|
31
|
+
source: ConfigSource
|
|
32
|
+
path: Path
|
|
33
|
+
data: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# MCP server configuration (6 transport types)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class McpStdioServerConfig:
|
|
42
|
+
command: str
|
|
43
|
+
args: list[str] = field(default_factory=list)
|
|
44
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
tool_call_timeout_ms: int | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class McpRemoteServerConfig:
|
|
50
|
+
url: str
|
|
51
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
headers_helper: str | None = None
|
|
53
|
+
auth: str | None = None # "oauth" or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class McpWebSocketServerConfig:
|
|
58
|
+
url: str
|
|
59
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class McpSdkServerConfig:
|
|
64
|
+
name: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class McpManagedProxyServerConfig:
|
|
69
|
+
url: str
|
|
70
|
+
id: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
McpServerConfig = (
|
|
74
|
+
McpStdioServerConfig
|
|
75
|
+
| McpRemoteServerConfig
|
|
76
|
+
| McpWebSocketServerConfig
|
|
77
|
+
| McpSdkServerConfig
|
|
78
|
+
| McpManagedProxyServerConfig
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_mcp_server_config(name: str, data: dict[str, Any]) -> McpServerConfig | None:
|
|
83
|
+
"""Parse a single MCP server config from JSON."""
|
|
84
|
+
server_type = data.get("type", "stdio")
|
|
85
|
+
|
|
86
|
+
if server_type == "stdio":
|
|
87
|
+
return McpStdioServerConfig(
|
|
88
|
+
command=data.get("command", ""),
|
|
89
|
+
args=data.get("args", []),
|
|
90
|
+
env=data.get("env", {}),
|
|
91
|
+
tool_call_timeout_ms=data.get("toolCallTimeoutMs"),
|
|
92
|
+
)
|
|
93
|
+
if server_type in ("sse", "http"):
|
|
94
|
+
return McpRemoteServerConfig(
|
|
95
|
+
url=data.get("url", ""),
|
|
96
|
+
headers=data.get("headers", {}),
|
|
97
|
+
headers_helper=data.get("headersHelper"),
|
|
98
|
+
auth=data.get("auth"),
|
|
99
|
+
)
|
|
100
|
+
if server_type == "ws":
|
|
101
|
+
return McpWebSocketServerConfig(
|
|
102
|
+
url=data.get("url", ""),
|
|
103
|
+
headers=data.get("headers", {}),
|
|
104
|
+
)
|
|
105
|
+
if server_type == "sdk":
|
|
106
|
+
return McpSdkServerConfig(name=data.get("name", name))
|
|
107
|
+
if server_type == "managed_proxy":
|
|
108
|
+
return McpManagedProxyServerConfig(
|
|
109
|
+
url=data.get("url", ""),
|
|
110
|
+
id=data.get("id", ""),
|
|
111
|
+
)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# OAuth configuration
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class OAuthConfig:
|
|
121
|
+
client_id: str = ""
|
|
122
|
+
authorize_url: str = ""
|
|
123
|
+
token_url: str = ""
|
|
124
|
+
callback_port: int = 4545
|
|
125
|
+
scopes: list[str] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Hook configuration
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class HookMatcher:
|
|
134
|
+
"""Matches specific tools for hook execution."""
|
|
135
|
+
|
|
136
|
+
tool_name: str | None = None
|
|
137
|
+
tool_prefix: str | None = None
|
|
138
|
+
|
|
139
|
+
def matches(self, tool_name: str) -> bool:
|
|
140
|
+
if self.tool_name and self.tool_name == tool_name:
|
|
141
|
+
return True
|
|
142
|
+
if self.tool_prefix and tool_name.startswith(self.tool_prefix):
|
|
143
|
+
return True
|
|
144
|
+
if self.tool_name is None and self.tool_prefix is None:
|
|
145
|
+
return True # Match all
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class HookEntry:
|
|
151
|
+
"""A single hook configuration entry."""
|
|
152
|
+
|
|
153
|
+
command: str
|
|
154
|
+
timeout_ms: int = 10_000
|
|
155
|
+
matchers: list[HookMatcher] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
def matches_tool(self, tool_name: str) -> bool:
|
|
158
|
+
if not self.matchers:
|
|
159
|
+
return True
|
|
160
|
+
return any(m.matches(tool_name) for m in self.matchers)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class RuntimeHookConfig:
|
|
165
|
+
pre_tool_use: list[HookEntry] = field(default_factory=list)
|
|
166
|
+
post_tool_use: list[HookEntry] = field(default_factory=list)
|
|
167
|
+
post_tool_use_failure: list[HookEntry] = field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_hook_entries(data: list[Any]) -> list[HookEntry]:
|
|
171
|
+
"""Parse hook entries from config."""
|
|
172
|
+
entries: list[HookEntry] = []
|
|
173
|
+
for item in data:
|
|
174
|
+
if isinstance(item, str):
|
|
175
|
+
entries.append(HookEntry(command=item))
|
|
176
|
+
elif isinstance(item, dict):
|
|
177
|
+
matchers = []
|
|
178
|
+
for m in item.get("matchers", []):
|
|
179
|
+
matchers.append(HookMatcher(
|
|
180
|
+
tool_name=m.get("tool_name"),
|
|
181
|
+
tool_prefix=m.get("tool_prefix"),
|
|
182
|
+
))
|
|
183
|
+
entries.append(HookEntry(
|
|
184
|
+
command=item.get("command", ""),
|
|
185
|
+
timeout_ms=item.get("timeout_ms", 10_000),
|
|
186
|
+
matchers=matchers,
|
|
187
|
+
))
|
|
188
|
+
return entries
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Feature configuration
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class RuntimeFeatureConfig:
|
|
197
|
+
"""Feature configuration extracted from merged config."""
|
|
198
|
+
|
|
199
|
+
hooks: RuntimeHookConfig = field(default_factory=RuntimeHookConfig)
|
|
200
|
+
plugins: dict[str, Any] = field(default_factory=dict)
|
|
201
|
+
mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict)
|
|
202
|
+
oauth: OAuthConfig | None = None
|
|
203
|
+
model: str | None = None
|
|
204
|
+
permission_mode: str | None = None
|
|
205
|
+
allowed_tools: list[str] | None = None
|
|
206
|
+
denied_tools: list[str] | None = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class RuntimeConfig:
|
|
211
|
+
"""Fully resolved configuration from all sources."""
|
|
212
|
+
|
|
213
|
+
merged: dict[str, Any] = field(default_factory=dict)
|
|
214
|
+
loaded_entries: list[ConfigEntry] = field(default_factory=list)
|
|
215
|
+
feature_config: RuntimeFeatureConfig = field(default_factory=RuntimeFeatureConfig)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Config loader
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
class ConfigLoader:
|
|
223
|
+
"""Loads and merges configuration from user, project, and local sources.
|
|
224
|
+
|
|
225
|
+
Maps to: rust/crates/runtime/src/config.rs::ConfigLoader
|
|
226
|
+
|
|
227
|
+
Resolution order (later overrides earlier):
|
|
228
|
+
1. User: ~/.axion/settings.json, ~/.config/axion/settings.json, ~/.claude/settings.json
|
|
229
|
+
2. Project: .axion.json, .claude.json
|
|
230
|
+
3. Local: .axion/settings.json, .claude/settings.json
|
|
231
|
+
4. Local: .axion/settings.local.json, .claude/settings.local.json
|
|
232
|
+
5. Environment variables
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, project_dir: Path | None = None) -> None:
|
|
236
|
+
self.project_dir = project_dir or Path.cwd()
|
|
237
|
+
|
|
238
|
+
def load(self) -> RuntimeConfig:
|
|
239
|
+
"""Load and merge all configuration sources."""
|
|
240
|
+
entries: list[ConfigEntry] = []
|
|
241
|
+
merged: dict[str, Any] = {}
|
|
242
|
+
|
|
243
|
+
# 1. User config
|
|
244
|
+
for user_path in self._user_config_paths():
|
|
245
|
+
data = self._load_json(user_path)
|
|
246
|
+
if data is not None:
|
|
247
|
+
entries.append(ConfigEntry(source=ConfigSource.USER, path=user_path, data=data))
|
|
248
|
+
self._deep_merge(merged, data)
|
|
249
|
+
|
|
250
|
+
# 2. Project config (.axion.json first, .claude.json fallback)
|
|
251
|
+
for proj_path in [
|
|
252
|
+
self.project_dir / ".axion.json",
|
|
253
|
+
self.project_dir / ".claude.json",
|
|
254
|
+
]:
|
|
255
|
+
data = self._load_json(proj_path)
|
|
256
|
+
if data is not None:
|
|
257
|
+
entries.append(ConfigEntry(source=ConfigSource.PROJECT, path=proj_path, data=data))
|
|
258
|
+
self._deep_merge(merged, data)
|
|
259
|
+
|
|
260
|
+
# 3-4. Local config (.axion/ first, .claude/ fallback)
|
|
261
|
+
for local_path in [
|
|
262
|
+
self.project_dir / ".axion" / "settings.json",
|
|
263
|
+
self.project_dir / ".axion" / "settings.local.json",
|
|
264
|
+
self.project_dir / ".claude" / "settings.json",
|
|
265
|
+
self.project_dir / ".claude" / "settings.local.json",
|
|
266
|
+
]:
|
|
267
|
+
data = self._load_json(local_path)
|
|
268
|
+
if data is not None:
|
|
269
|
+
entries.append(ConfigEntry(source=ConfigSource.LOCAL, path=local_path, data=data))
|
|
270
|
+
self._deep_merge(merged, data)
|
|
271
|
+
|
|
272
|
+
# 5. Environment overrides
|
|
273
|
+
env_model = os.environ.get("AXION_MODEL") or os.environ.get("CLAUDE_MODEL")
|
|
274
|
+
|
|
275
|
+
# Build feature config
|
|
276
|
+
feature = self._extract_features(merged)
|
|
277
|
+
if env_model:
|
|
278
|
+
feature.model = env_model
|
|
279
|
+
|
|
280
|
+
return RuntimeConfig(merged=merged, loaded_entries=entries, feature_config=feature)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def default_for(cls, cwd: Path) -> ConfigLoader:
|
|
284
|
+
"""Create a loader for the given working directory."""
|
|
285
|
+
return cls(project_dir=cwd)
|
|
286
|
+
|
|
287
|
+
def _extract_features(self, merged: dict[str, Any]) -> RuntimeFeatureConfig:
|
|
288
|
+
"""Extract feature configuration from merged config."""
|
|
289
|
+
feature = RuntimeFeatureConfig()
|
|
290
|
+
|
|
291
|
+
# Permissions
|
|
292
|
+
perms = merged.get("permissions", {})
|
|
293
|
+
feature.permission_mode = perms.get("defaultMode")
|
|
294
|
+
feature.allowed_tools = perms.get("allowedTools")
|
|
295
|
+
feature.denied_tools = perms.get("deniedTools")
|
|
296
|
+
|
|
297
|
+
# Model
|
|
298
|
+
feature.model = merged.get("model")
|
|
299
|
+
|
|
300
|
+
# Hooks
|
|
301
|
+
hooks_data = merged.get("hooks", {})
|
|
302
|
+
if hooks_data:
|
|
303
|
+
feature.hooks = RuntimeHookConfig(
|
|
304
|
+
pre_tool_use=parse_hook_entries(hooks_data.get("preToolUse", [])),
|
|
305
|
+
post_tool_use=parse_hook_entries(hooks_data.get("postToolUse", [])),
|
|
306
|
+
post_tool_use_failure=parse_hook_entries(
|
|
307
|
+
hooks_data.get("postToolUseFailure", [])
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# MCP servers (full parsing)
|
|
312
|
+
mcp_data = merged.get("mcpServers", {})
|
|
313
|
+
for name, server_data in mcp_data.items():
|
|
314
|
+
if isinstance(server_data, dict):
|
|
315
|
+
config = parse_mcp_server_config(name, server_data)
|
|
316
|
+
if config:
|
|
317
|
+
feature.mcp_servers[name] = config
|
|
318
|
+
|
|
319
|
+
# OAuth
|
|
320
|
+
oauth_data = merged.get("oauth", {})
|
|
321
|
+
if oauth_data:
|
|
322
|
+
feature.oauth = OAuthConfig(
|
|
323
|
+
client_id=oauth_data.get("clientId", ""),
|
|
324
|
+
authorize_url=oauth_data.get("authorizeUrl", ""),
|
|
325
|
+
token_url=oauth_data.get("tokenUrl", ""),
|
|
326
|
+
callback_port=oauth_data.get("callbackPort", 4545),
|
|
327
|
+
scopes=oauth_data.get("scopes", []),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Plugins
|
|
331
|
+
feature.plugins = merged.get("plugins", {})
|
|
332
|
+
|
|
333
|
+
return feature
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _user_config_paths() -> list[Path]:
|
|
337
|
+
home = Path.home()
|
|
338
|
+
paths = [
|
|
339
|
+
home / ".axion" / "settings.json",
|
|
340
|
+
home / ".config" / "axion" / "settings.json",
|
|
341
|
+
home / ".claude" / "settings.json", # backwards compat
|
|
342
|
+
]
|
|
343
|
+
# Check CLAUDE_CONFIG_DIR env
|
|
344
|
+
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
345
|
+
if config_dir:
|
|
346
|
+
paths.insert(0, Path(config_dir) / "settings.json")
|
|
347
|
+
return paths
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _load_json(path: Path) -> dict[str, Any] | None:
|
|
351
|
+
if not path.exists():
|
|
352
|
+
return None
|
|
353
|
+
try:
|
|
354
|
+
text = path.read_text(encoding="utf-8")
|
|
355
|
+
data = json.loads(text)
|
|
356
|
+
return data if isinstance(data, dict) else None
|
|
357
|
+
except (json.JSONDecodeError, OSError):
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> None:
|
|
362
|
+
for key, value in override.items():
|
|
363
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
364
|
+
ConfigLoader._deep_merge(base[key], value)
|
|
365
|
+
else:
|
|
366
|
+
base[key] = value
|
|
367
|
+
|
|
368
|
+
def render_config_report(self) -> str:
|
|
369
|
+
"""Render a human-readable configuration report."""
|
|
370
|
+
config = self.load()
|
|
371
|
+
lines = ["Configuration:"]
|
|
372
|
+
for entry in config.loaded_entries:
|
|
373
|
+
lines.append(f" [{entry.source.value}] {entry.path}")
|
|
374
|
+
|
|
375
|
+
fc = config.feature_config
|
|
376
|
+
lines.append(f"\n Model: {fc.model or '(default)'}")
|
|
377
|
+
lines.append(f" Permission mode: {fc.permission_mode or '(default)'}")
|
|
378
|
+
|
|
379
|
+
if fc.mcp_servers:
|
|
380
|
+
lines.append(f"\n MCP servers ({len(fc.mcp_servers)}):")
|
|
381
|
+
for name, srv in fc.mcp_servers.items():
|
|
382
|
+
if isinstance(srv, McpStdioServerConfig):
|
|
383
|
+
lines.append(f" {name}: stdio → {srv.command}")
|
|
384
|
+
elif isinstance(srv, McpRemoteServerConfig):
|
|
385
|
+
lines.append(f" {name}: remote → {srv.url}")
|
|
386
|
+
else:
|
|
387
|
+
lines.append(f" {name}: {type(srv).__name__}")
|
|
388
|
+
|
|
389
|
+
if fc.hooks.pre_tool_use or fc.hooks.post_tool_use:
|
|
390
|
+
hook_count = (
|
|
391
|
+
len(fc.hooks.pre_tool_use)
|
|
392
|
+
+ len(fc.hooks.post_tool_use)
|
|
393
|
+
+ len(fc.hooks.post_tool_use_failure)
|
|
394
|
+
)
|
|
395
|
+
lines.append(f"\n Hooks: {hook_count} configured")
|
|
396
|
+
|
|
397
|
+
return "\n".join(lines)
|