agent-brain-cli 10.0.7__tar.gz → 10.2.0__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 (42) hide show
  1. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/PKG-INFO +3 -2
  2. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/__init__.py +1 -1
  3. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/cli.py +43 -3
  4. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/client/api_client.py +89 -16
  5. agent_brain_cli-10.2.0/agent_brain_cli/client/transport.py +54 -0
  6. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/cache.py +16 -8
  7. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/folders.py +23 -9
  8. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/index.py +9 -12
  9. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/inject.py +20 -12
  10. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/jobs.py +8 -3
  11. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/query.py +68 -8
  12. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/reset.py +11 -6
  13. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/start.py +75 -3
  14. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/status.py +13 -5
  15. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config.py +73 -1
  16. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/pyproject.toml +3 -2
  17. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/README.md +0 -0
  18. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/client/__init__.py +0 -0
  19. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/__init__.py +0 -0
  20. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/config.py +0 -0
  21. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/doctor.py +0 -0
  22. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/init.py +0 -0
  23. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/install_agent.py +0 -0
  24. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/list_cmd.py +0 -0
  25. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/stop.py +0 -0
  26. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/types.py +0 -0
  27. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/commands/uninstall.py +0 -0
  28. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config_migrate.py +0 -0
  29. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/config_schema.py +0 -0
  30. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/diagnostics.py +0 -0
  31. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/migration.py +0 -0
  32. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/__init__.py +0 -0
  33. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/claude_converter.py +0 -0
  34. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/codex_converter.py +0 -0
  35. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/converter_base.py +0 -0
  36. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/gemini_converter.py +0 -0
  37. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/opencode_converter.py +0 -0
  38. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/parser.py +0 -0
  39. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/skill_runtime_converter.py +0 -0
  40. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/tool_maps.py +0 -0
  41. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/runtime/types.py +0 -0
  42. {agent_brain_cli-10.0.7 → agent_brain_cli-10.2.0}/agent_brain_cli/xdg_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: agent-brain-cli
3
- Version: 10.0.7
3
+ Version: 10.2.0
4
4
  Summary: Agent Brain CLI - Command-line interface for managing AI agent memory and knowledge retrieval
5
5
  Home-page: https://github.com/SpillwaveSolutions/agent-brain
6
6
  License: MIT
@@ -15,7 +15,8 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
- Requires-Dist: agent-brain-rag (>=10.0.7,<11.0.0)
18
+ Requires-Dist: agent-brain-rag (>=10.2.0,<11.0.0)
19
+ Requires-Dist: agent-brain-uds (>=10.2.0,<11.0.0)
19
20
  Requires-Dist: click (>=8.1.0,<9.0.0)
20
21
  Requires-Dist: httpx (>=0.28.0,<0.29.0)
21
22
  Requires-Dist: pydantic (>=2.10.0,<3.0.0)
@@ -1,3 +1,3 @@
1
1
  """Doc-Serve CLI - Command-line interface for managing Doc-Serve server."""
2
2
 
3
- __version__ = "10.0.7"
3
+ __version__ = "10.2.0"
@@ -30,7 +30,41 @@ from .commands import (
30
30
 
31
31
  @click.group()
32
32
  @click.version_option(version=__version__, prog_name="agent-brain")
33
- def cli() -> None:
33
+ @click.option(
34
+ "--transport",
35
+ "transport",
36
+ type=click.Choice(["auto", "http", "uds"], case_sensitive=False),
37
+ default=None,
38
+ help=(
39
+ "Transport to use: auto (default — UDS if available, HTTP "
40
+ "otherwise), http, or uds. Honors AGENT_BRAIN_TRANSPORT env."
41
+ ),
42
+ )
43
+ @click.option(
44
+ "--socket-path",
45
+ type=click.Path(),
46
+ default=None,
47
+ help="Override UDS socket path (only used with --transport=uds|auto).",
48
+ )
49
+ @click.option(
50
+ "--base-url",
51
+ default=None,
52
+ help="Override server base URL (only used with --transport=http|auto).",
53
+ )
54
+ @click.option(
55
+ "--debug-transport",
56
+ is_flag=True,
57
+ default=False,
58
+ help="Log the resolved transport (http or uds) and target to stderr.",
59
+ )
60
+ @click.pass_context
61
+ def cli(
62
+ ctx: click.Context,
63
+ transport: str | None,
64
+ socket_path: str | None,
65
+ base_url: str | None,
66
+ debug_transport: bool,
67
+ ) -> None:
34
68
  """Agent Brain CLI - Manage and query the Agent Brain RAG server.
35
69
 
36
70
  A command-line interface for interacting with the Agent Brain document
@@ -81,9 +115,15 @@ def cli() -> None:
81
115
 
82
116
  \b
83
117
  Environment Variables:
84
- AGENT_BRAIN_URL Server URL (default: http://127.0.0.1:8000)
118
+ AGENT_BRAIN_URL Server URL (default: http://127.0.0.1:8000)
119
+ AGENT_BRAIN_TRANSPORT Transport hint: auto, http, or uds
120
+ AGENT_BRAIN_UDS_PATH Override UDS socket path
85
121
  """
86
- pass
122
+ ctx.ensure_object(dict)
123
+ ctx.obj["transport_hint"] = transport
124
+ ctx.obj["base_url_override"] = base_url
125
+ ctx.obj["socket_path_override"] = socket_path
126
+ ctx.obj["debug_transport"] = debug_transport
87
127
 
88
128
 
89
129
  # Register project management commands
@@ -53,6 +53,22 @@ class IndexingStatus:
53
53
  embedding_cache: dict[str, Any] | None = None
54
54
 
55
55
 
56
+ @dataclass
57
+ class ResultExplanation:
58
+ """Structured per-result explanation (issue #159).
59
+
60
+ Populated only when the request set `explain=true`. All fields are
61
+ optional because their relevance depends on retrieval mode.
62
+ """
63
+
64
+ reason: str
65
+ matched_terms: list[str] | None = None
66
+ fusion: dict[str, float] | None = None
67
+ graph_path: list[str] | None = None
68
+ rerank_movement: int | None = None
69
+ graph_fallback: bool | None = None
70
+
71
+
56
72
  @dataclass
57
73
  class QueryResult:
58
74
  """Single query result."""
@@ -64,6 +80,14 @@ class QueryResult:
64
80
  metadata: dict[str, Any]
65
81
  vector_score: float | None = None
66
82
  bm25_score: float | None = None
83
+ graph_score: float | None = None
84
+ rerank_score: float | None = None
85
+ original_rank: int | None = None
86
+ relationship_path: list[str] | None = None
87
+ related_entities: list[str] | None = None
88
+ source_type: str = "doc"
89
+ language: str | None = None
90
+ explanation: ResultExplanation | None = None
67
91
 
68
92
 
69
93
  @dataclass
@@ -95,6 +119,39 @@ class IndexResponse:
95
119
  message: str | None
96
120
 
97
121
 
122
+ def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
123
+ """Build a QueryResult from a server response dict, including optional
124
+ explanation block (issue #159)."""
125
+ explanation_data = payload.get("explanation")
126
+ explanation: ResultExplanation | None = None
127
+ if explanation_data is not None:
128
+ explanation = ResultExplanation(
129
+ reason=explanation_data.get("reason", ""),
130
+ matched_terms=explanation_data.get("matched_terms"),
131
+ fusion=explanation_data.get("fusion"),
132
+ graph_path=explanation_data.get("graph_path"),
133
+ rerank_movement=explanation_data.get("rerank_movement"),
134
+ graph_fallback=explanation_data.get("graph_fallback"),
135
+ )
136
+ return QueryResult(
137
+ text=payload["text"],
138
+ source=payload["source"],
139
+ score=payload["score"],
140
+ chunk_id=payload["chunk_id"],
141
+ metadata=payload.get("metadata", {}),
142
+ vector_score=payload.get("vector_score"),
143
+ bm25_score=payload.get("bm25_score"),
144
+ graph_score=payload.get("graph_score"),
145
+ rerank_score=payload.get("rerank_score"),
146
+ original_rank=payload.get("original_rank"),
147
+ relationship_path=payload.get("relationship_path"),
148
+ related_entities=payload.get("related_entities"),
149
+ source_type=payload.get("source_type", "doc"),
150
+ language=payload.get("language"),
151
+ explanation=explanation,
152
+ )
153
+
154
+
98
155
  class DocServeClient:
99
156
  """HTTP client for Doc-Serve API."""
100
157
 
@@ -114,6 +171,29 @@ class DocServeClient:
114
171
  self.timeout = timeout
115
172
  self._client = httpx.Client(timeout=timeout)
116
173
 
174
+ @classmethod
175
+ def from_httpx(cls, client: httpx.Client) -> "DocServeClient":
176
+ """Build a DocServeClient that uses a pre-constructed httpx.Client.
177
+
178
+ Used by the transport selector to inject a UDS-backed client
179
+ (see ``agent_brain_cli.client.transport.open_client``). The
180
+ inner client's ``base_url`` is preserved; this wrapper sends
181
+ relative paths only.
182
+
183
+ Args:
184
+ client: An already-configured ``httpx.Client``. The wrapper
185
+ takes ownership and will close it on ``__exit__``.
186
+
187
+ Returns:
188
+ A DocServeClient backed by ``client``.
189
+ """
190
+ instance = cls.__new__(cls)
191
+ instance.base_url = "" # inner client carries the real base_url
192
+ timeout = client.timeout
193
+ instance.timeout = timeout.read or 30.0
194
+ instance._client = client
195
+ return instance
196
+
117
197
  def __enter__(self) -> "DocServeClient":
118
198
  return self
119
199
 
@@ -229,6 +309,7 @@ class DocServeClient:
229
309
  source_types: list[str] | None = None,
230
310
  languages: list[str] | None = None,
231
311
  file_paths: list[str] | None = None,
312
+ explain: bool = False,
232
313
  ) -> QueryResponse:
233
314
  """
234
315
  Query indexed documents.
@@ -242,11 +323,14 @@ class DocServeClient:
242
323
  source_types: Filter by source types (doc, code, test).
243
324
  languages: Filter by programming languages.
244
325
  file_paths: Filter by file path patterns.
326
+ explain: When True, include structured per-result explanations
327
+ (matched terms, fusion breakdown, graph path, rerank movement,
328
+ and a 'why this rank' summary). See issue #159.
245
329
 
246
330
  Returns:
247
331
  QueryResponse with matching results.
248
332
  """
249
- request_data = {
333
+ request_data: dict[str, Any] = {
250
334
  "query": query_text,
251
335
  "top_k": top_k,
252
336
  "similarity_threshold": similarity_threshold,
@@ -259,21 +343,12 @@ class DocServeClient:
259
343
  request_data["languages"] = languages
260
344
  if file_paths is not None:
261
345
  request_data["file_paths"] = file_paths
346
+ if explain:
347
+ request_data["explain"] = True
262
348
 
263
349
  data = self._request("POST", "/query/", json=request_data)
264
350
 
265
- results = [
266
- QueryResult(
267
- text=r["text"],
268
- source=r["source"],
269
- score=r["score"],
270
- chunk_id=r["chunk_id"],
271
- metadata=r.get("metadata", {}),
272
- vector_score=r.get("vector_score"),
273
- bm25_score=r.get("bm25_score"),
274
- )
275
- for r in data.get("results", [])
276
- ]
351
+ results = [_parse_query_result(r) for r in data.get("results", [])]
277
352
 
278
353
  return QueryResponse(
279
354
  results=results,
@@ -295,7 +370,6 @@ class DocServeClient:
295
370
  include_types: list[str] | None = None,
296
371
  generate_summaries: bool = False,
297
372
  force: bool = False,
298
- allow_external: bool = False,
299
373
  injector_script: str | None = None,
300
374
  folder_metadata_file: str | None = None,
301
375
  dry_run: bool = False,
@@ -318,7 +392,6 @@ class DocServeClient:
318
392
  include_types: File type preset names (e.g., ["python", "docs"]).
319
393
  generate_summaries: Generate LLM summaries for code chunks.
320
394
  force: Bypass deduplication and force a new job.
321
- allow_external: Allow paths outside the project directory.
322
395
  injector_script: Path to Python script exporting process_chunk().
323
396
  folder_metadata_file: Path to JSON file with static metadata.
324
397
  dry_run: Validate injector against sample chunks without indexing.
@@ -358,7 +431,7 @@ class DocServeClient:
358
431
  "POST",
359
432
  "/index/",
360
433
  json=body,
361
- params={"force": force, "allow_external": allow_external},
434
+ params={"force": force},
362
435
  )
363
436
 
364
437
  return IndexResponse(
@@ -0,0 +1,54 @@
1
+ """Transport selector — builds a DocServeClient over HTTP or UDS.
2
+
3
+ Plan §4.4: every CLI command should call ``open_client(ctx)`` instead of
4
+ ``DocServeClient(base_url=resolved_url)`` directly. The selector reads
5
+ transport-related state off the Click context, calls
6
+ :func:`agent_brain_cli.config.resolve_transport`, then constructs the
7
+ appropriate wrapper.
8
+
9
+ The selector is intentionally tiny — three branches and one
10
+ ``from_httpx`` call. Resolution logic stays in ``config.py`` so it is
11
+ testable without a live ``httpx.Client``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from ..config import resolve_transport
21
+ from .api_client import DocServeClient
22
+
23
+
24
+ def open_client(ctx: click.Context, *, timeout: float = 30.0) -> DocServeClient:
25
+ """Construct a ``DocServeClient`` over the transport selected by ``ctx``.
26
+
27
+ Args:
28
+ ctx: Click context. Reads optional keys from ``ctx.obj``:
29
+ ``transport_hint`` (``"http"`` / ``"uds"`` / ``"auto"`` /
30
+ ``None``), ``base_url_override``, ``socket_path_override``,
31
+ and ``debug_transport``.
32
+ timeout: HTTP request timeout in seconds. Defaults to 30.
33
+
34
+ Returns:
35
+ A live ``DocServeClient``. The caller is responsible for closing
36
+ it (use as a context manager).
37
+ """
38
+ obj = ctx.obj or {}
39
+ transport, target = resolve_transport(
40
+ transport_hint=obj.get("transport_hint"),
41
+ base_url_override=obj.get("base_url_override"),
42
+ socket_path_override=obj.get("socket_path_override"),
43
+ )
44
+ if obj.get("debug_transport"):
45
+ click.echo(f"[debug-transport] {transport} -> {target}", err=True)
46
+
47
+ if transport == "http":
48
+ return DocServeClient(base_url=target, timeout=timeout)
49
+
50
+ # UDS: import lazily so HTTP-only invocations don't pay the cost.
51
+ from agent_brain_uds import make_client
52
+
53
+ inner = make_client(socket_path=Path(target), timeout=timeout)
54
+ return DocServeClient.from_httpx(inner)
@@ -5,8 +5,8 @@ from rich.console import Console
5
5
  from rich.prompt import Confirm
6
6
  from rich.table import Table
7
7
 
8
- from ..client import ConnectionError, DocServeClient, ServerError
9
- from ..config import get_server_url
8
+ from ..client import ConnectionError, ServerError
9
+ from ..client.transport import open_client
10
10
 
11
11
  console = Console()
12
12
 
@@ -25,11 +25,15 @@ def cache_group() -> None:
25
25
  help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
26
26
  )
27
27
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
28
- def cache_status(url: str | None, json_output: bool) -> None:
28
+ @click.pass_context
29
+ def cache_status(ctx: click.Context, url: str | None, json_output: bool) -> None:
29
30
  """Show embedding cache statistics."""
30
- resolved_url = url or get_server_url()
31
+ if url:
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["base_url_override"] = url
34
+ ctx.obj["transport_hint"] = "http"
31
35
  try:
32
- with DocServeClient(base_url=resolved_url) as client:
36
+ with open_client(ctx) as client:
33
37
  data = client.cache_status()
34
38
 
35
39
  if json_output:
@@ -91,14 +95,18 @@ def cache_status(url: str | None, json_output: bool) -> None:
91
95
  is_flag=True,
92
96
  help="Skip confirmation prompt",
93
97
  )
94
- def cache_clear(url: str | None, yes: bool) -> None:
98
+ @click.pass_context
99
+ def cache_clear(ctx: click.Context, url: str | None, yes: bool) -> None:
95
100
  """Clear all cached embeddings from the cache.
96
101
 
97
102
  Without --yes, shows the current entry count and prompts for confirmation.
98
103
  """
99
- resolved_url = url or get_server_url()
104
+ if url:
105
+ ctx.ensure_object(dict)
106
+ ctx.obj["base_url_override"] = url
107
+ ctx.obj["transport_hint"] = "http"
100
108
  try:
101
- with DocServeClient(base_url=resolved_url) as client:
109
+ with open_client(ctx) as client:
102
110
  if not yes:
103
111
  # Get current count before asking
104
112
  try:
@@ -7,8 +7,8 @@ import click
7
7
  from rich.console import Console
8
8
  from rich.table import Table
9
9
 
10
- from ..client import ConnectionError, DocServeClient, ServerError
11
- from ..config import get_server_url
10
+ from ..client import ConnectionError, ServerError
11
+ from ..client.transport import open_client
12
12
 
13
13
  console = Console()
14
14
 
@@ -34,7 +34,8 @@ def folders_group() -> None:
34
34
  help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
35
35
  )
36
36
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
37
- def list_folders_cmd(url: str | None, json_output: bool) -> None:
37
+ @click.pass_context
38
+ def list_folders_cmd(ctx: click.Context, url: str | None, json_output: bool) -> None:
38
39
  """List all indexed folders with chunk counts and last indexed time.
39
40
 
40
41
  \b
@@ -42,10 +43,13 @@ def list_folders_cmd(url: str | None, json_output: bool) -> None:
42
43
  agent-brain folders list
43
44
  agent-brain folders list --json
44
45
  """
45
- resolved_url = url or get_server_url()
46
+ if url:
47
+ ctx.ensure_object(dict)
48
+ ctx.obj["base_url_override"] = url
49
+ ctx.obj["transport_hint"] = "http"
46
50
 
47
51
  try:
48
- with DocServeClient(base_url=resolved_url) as client:
52
+ with open_client(ctx) as client:
49
53
  folders = client.list_folders()
50
54
 
51
55
  if json_output:
@@ -139,7 +143,9 @@ def list_folders_cmd(url: str | None, json_output: bool) -> None:
139
143
  help="Debounce interval in seconds for file watching (default: 30)",
140
144
  )
141
145
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
146
+ @click.pass_context
142
147
  def add_folder_cmd(
148
+ ctx: click.Context,
143
149
  folder_path: str,
144
150
  url: str | None,
145
151
  include_code: bool,
@@ -157,11 +163,14 @@ def add_folder_cmd(
157
163
  agent-brain folders add ./src --include-code
158
164
  agent-brain folders add ./src --watch auto --debounce 10
159
165
  """
160
- resolved_url = url or get_server_url()
166
+ if url:
167
+ ctx.ensure_object(dict)
168
+ ctx.obj["base_url_override"] = url
169
+ ctx.obj["transport_hint"] = "http"
161
170
  folder = Path(folder_path).resolve()
162
171
 
163
172
  try:
164
- with DocServeClient(base_url=resolved_url) as client:
173
+ with open_client(ctx) as client:
165
174
  response = client.index(
166
175
  folder_path=str(folder),
167
176
  include_code=include_code,
@@ -218,7 +227,9 @@ def add_folder_cmd(
218
227
  help="Skip confirmation prompt",
219
228
  )
220
229
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
230
+ @click.pass_context
221
231
  def remove_folder_cmd(
232
+ ctx: click.Context,
222
233
  folder_path: str,
223
234
  url: str | None,
224
235
  yes: bool,
@@ -234,7 +245,10 @@ def remove_folder_cmd(
234
245
  agent-brain folders remove ./docs --yes
235
246
  agent-brain folders remove /absolute/path/to/docs
236
247
  """
237
- resolved_url = url or get_server_url()
248
+ if url:
249
+ ctx.ensure_object(dict)
250
+ ctx.obj["base_url_override"] = url
251
+ ctx.obj["transport_hint"] = "http"
238
252
  resolved_path = str(Path(folder_path).resolve())
239
253
 
240
254
  if not yes:
@@ -244,7 +258,7 @@ def remove_folder_cmd(
244
258
  )
245
259
 
246
260
  try:
247
- with DocServeClient(base_url=resolved_url) as client:
261
+ with open_client(ctx) as client:
248
262
  result = client.delete_folder(folder_path=resolved_path)
249
263
 
250
264
  chunks_deleted = result.get("chunks_deleted", 0)
@@ -5,8 +5,8 @@ from pathlib import Path
5
5
  import click
6
6
  from rich.console import Console
7
7
 
8
- from ..client import ConnectionError, DocServeClient, ServerError
9
- from ..config import get_server_url
8
+ from ..client import ConnectionError, ServerError
9
+ from ..client.transport import open_client
10
10
  from ..diagnostics import doctor_hint_message
11
11
 
12
12
  console = Console()
@@ -79,13 +79,10 @@ console = Console()
79
79
  is_flag=True,
80
80
  help="Force re-indexing even if embedding provider has changed",
81
81
  )
82
- @click.option(
83
- "--allow-external",
84
- is_flag=True,
85
- help="Allow indexing paths outside the project directory",
86
- )
87
82
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
83
+ @click.pass_context
88
84
  def index_command(
85
+ ctx: click.Context,
89
86
  folder_path: str,
90
87
  url: str | None,
91
88
  chunk_size: int,
@@ -99,15 +96,16 @@ def index_command(
99
96
  exclude_patterns: str | None,
100
97
  generate_summaries: bool,
101
98
  force: bool,
102
- allow_external: bool,
103
99
  json_output: bool,
104
100
  ) -> None:
105
101
  """Index documents from a folder.
106
102
 
107
103
  FOLDER_PATH: Path to the folder containing documents to index.
108
104
  """
109
- # Get URL from config if not specified
110
- resolved_url = url or get_server_url()
105
+ if url:
106
+ ctx.ensure_object(dict)
107
+ ctx.obj["base_url_override"] = url
108
+ ctx.obj["transport_hint"] = "http"
111
109
 
112
110
  # Resolve to absolute path
113
111
  folder = Path(folder_path).resolve()
@@ -131,7 +129,7 @@ def index_command(
131
129
  )
132
130
 
133
131
  try:
134
- with DocServeClient(base_url=resolved_url) as client:
132
+ with open_client(ctx) as client:
135
133
  response = client.index(
136
134
  folder_path=str(folder),
137
135
  chunk_size=chunk_size,
@@ -145,7 +143,6 @@ def index_command(
145
143
  exclude_patterns=exclude_patterns_list,
146
144
  generate_summaries=generate_summaries,
147
145
  force=force,
148
- allow_external=allow_external,
149
146
  )
150
147
 
151
148
  if json_output:
@@ -5,8 +5,8 @@ from pathlib import Path
5
5
  import click
6
6
  from rich.console import Console
7
7
 
8
- from ..client import ConnectionError, DocServeClient, ServerError
9
- from ..config import get_server_url
8
+ from ..client import ConnectionError, ServerError
9
+ from ..client.transport import open_client
10
10
 
11
11
  console = Console()
12
12
 
@@ -98,13 +98,10 @@ console = Console()
98
98
  is_flag=True,
99
99
  help="Force re-indexing even if embedding provider has changed",
100
100
  )
101
- @click.option(
102
- "--allow-external",
103
- is_flag=True,
104
- help="Allow indexing paths outside the project directory",
105
- )
106
101
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
102
+ @click.pass_context
107
103
  def inject_command(
104
+ ctx: click.Context,
108
105
  folder_path: str,
109
106
  injector_script: str | None,
110
107
  folder_metadata: str | None,
@@ -121,7 +118,6 @@ def inject_command(
121
118
  exclude_patterns: str | None,
122
119
  generate_summaries: bool,
123
120
  force: bool,
124
- allow_external: bool,
125
121
  json_output: bool,
126
122
  ) -> None:
127
123
  """Index documents from a folder with content injection.
@@ -152,8 +148,10 @@ def inject_command(
152
148
  )
153
149
  raise SystemExit(2)
154
150
 
155
- # Get URL from config if not specified
156
- resolved_url = url or get_server_url()
151
+ if url:
152
+ ctx.ensure_object(dict)
153
+ ctx.obj["base_url_override"] = url
154
+ ctx.obj["transport_hint"] = "http"
157
155
 
158
156
  # Resolve to absolute paths
159
157
  folder = Path(folder_path).resolve()
@@ -181,7 +179,7 @@ def inject_command(
181
179
  )
182
180
 
183
181
  try:
184
- with DocServeClient(base_url=resolved_url) as client:
182
+ with open_client(ctx) as client:
185
183
  response = client.index(
186
184
  folder_path=str(folder),
187
185
  chunk_size=chunk_size,
@@ -195,7 +193,6 @@ def inject_command(
195
193
  exclude_patterns=exclude_patterns_list,
196
194
  generate_summaries=generate_summaries,
197
195
  force=force,
198
- allow_external=allow_external,
199
196
  injector_script=resolved_script,
200
197
  folder_metadata_file=resolved_metadata,
201
198
  dry_run=dry_run,
@@ -268,4 +265,15 @@ def inject_command(
268
265
  "\n[dim]A conflict occurred. "
269
266
  "Check 'agent-brain jobs' for queue status.[/]"
270
267
  )
268
+ elif e.status_code == 403:
269
+ # Injector allowlist rejected the script (issue #181).
270
+ console.print(
271
+ "\n[dim]Injector scripts must be allowlisted in "
272
+ "[bold].agent-brain/config.yaml[/] before they can run on "
273
+ "the server. Add an entry under [bold]injector_scripts:[/] "
274
+ "with the script's path and the sha256 of its contents "
275
+ "(run [bold]sha256sum <script>[/] to get the hash). "
276
+ "See docs/USER_GUIDE.md 'Content Injection' for the full "
277
+ "schema.[/]"
278
+ )
271
279
  raise SystemExit(1) from e
@@ -9,7 +9,7 @@ from rich.panel import Panel
9
9
  from rich.table import Table
10
10
 
11
11
  from ..client import ConnectionError, DocServeClient, ServerError
12
- from ..config import get_server_url
12
+ from ..client.transport import open_client
13
13
  from ..diagnostics import doctor_hint_message
14
14
 
15
15
  console = Console()
@@ -285,7 +285,9 @@ def _watch_jobs(client: DocServeClient, limit: int) -> None:
285
285
  help="Agent Brain server URL (default: from config or http://127.0.0.1:8000)",
286
286
  )
287
287
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
288
+ @click.pass_context
288
289
  def jobs_command(
290
+ ctx: click.Context,
289
291
  job_id: str | None,
290
292
  watch: bool,
291
293
  cancel: bool,
@@ -305,7 +307,10 @@ def jobs_command(
305
307
  agent-brain jobs JOB_ID # Show job details
306
308
  agent-brain jobs JOB_ID --cancel # Cancel a job
307
309
  """
308
- resolved_url = url or get_server_url()
310
+ if url:
311
+ ctx.ensure_object(dict)
312
+ ctx.obj["base_url_override"] = url
313
+ ctx.obj["transport_hint"] = "http"
309
314
 
310
315
  # Validate options
311
316
  if cancel and not job_id:
@@ -318,7 +323,7 @@ def jobs_command(
318
323
  raise click.UsageError("--watch cannot be used with --json")
319
324
 
320
325
  try:
321
- with DocServeClient(base_url=resolved_url) as client:
326
+ with open_client(ctx) as client:
322
327
  if cancel and job_id:
323
328
  _cancel_job(client, job_id, json_output)
324
329
  elif watch:
@@ -3,18 +3,59 @@
3
3
  import click
4
4
  from rich.console import Console
5
5
  from rich.panel import Panel
6
+ from rich.table import Table
6
7
  from rich.text import Text
7
8
 
8
- from ..client import ConnectionError, DocServeClient, ServerError
9
- from ..config import get_server_url
9
+ from ..client import ConnectionError, ServerError
10
+ from ..client.api_client import ResultExplanation
11
+ from ..client.transport import open_client
10
12
  from ..diagnostics import doctor_hint_message
11
13
 
12
14
  console = Console()
13
15
 
14
16
 
15
- def _get_default_url() -> str:
16
- """Get default server URL from config."""
17
- return get_server_url()
17
+ def _render_explanation(explanation: ResultExplanation) -> None:
18
+ """Render a ResultExplanation as a sub-panel below the main result.
19
+
20
+ Layout (only rows that have data are emitted):
21
+ Why: <reason string>
22
+ Matched: term1, term2, ...
23
+ Fusion: key=value | key=value | ...
24
+ Graph: subject -> predicate -> object
25
+ Rerank: moved up/down N places (if any)
26
+ Fallback: graph -> vector
27
+ """
28
+ table = Table(show_header=False, box=None, padding=(0, 1), expand=False)
29
+ table.add_column("label", style="cyan", no_wrap=True)
30
+ table.add_column("value", style="dim")
31
+
32
+ table.add_row("Why:", explanation.reason)
33
+
34
+ if explanation.matched_terms:
35
+ highlighted = Text(", ".join(explanation.matched_terms))
36
+ highlighted.highlight_words(explanation.matched_terms, style="bold yellow")
37
+ table.add_row("Matched:", highlighted)
38
+
39
+ if explanation.fusion:
40
+ parts = [f"{k}={v:.4f}" for k, v in explanation.fusion.items()]
41
+ table.add_row("Fusion:", " | ".join(parts))
42
+
43
+ if explanation.graph_path:
44
+ table.add_row("Graph:", " -> ".join(explanation.graph_path))
45
+
46
+ if explanation.rerank_movement is not None:
47
+ if explanation.rerank_movement > 0:
48
+ arrow = f"+{explanation.rerank_movement} (moved up)"
49
+ elif explanation.rerank_movement < 0:
50
+ arrow = f"{explanation.rerank_movement} (moved down)"
51
+ else:
52
+ arrow = "0 (held position)"
53
+ table.add_row("Rerank:", arrow)
54
+
55
+ if explanation.graph_fallback:
56
+ table.add_row("Fallback:", "graph returned no hits -> vector")
57
+
58
+ console.print(table)
18
59
 
19
60
 
20
61
  @click.command("query")
@@ -63,6 +104,15 @@ def _get_default_url() -> str:
63
104
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
64
105
  @click.option("--full", is_flag=True, help="Show full text content")
65
106
  @click.option("--scores", is_flag=True, help="Show individual vector/BM25 scores")
107
+ @click.option(
108
+ "--explain",
109
+ is_flag=True,
110
+ help=(
111
+ "Show structured 'why this rank' explanations under each result: "
112
+ "matched terms, fusion breakdown, graph path, and rerank movement "
113
+ "(issue #159)."
114
+ ),
115
+ )
66
116
  @click.option(
67
117
  "--source-types",
68
118
  help="Comma-separated source types to filter by (doc,code,test)",
@@ -75,7 +125,9 @@ def _get_default_url() -> str:
75
125
  "--file-paths",
76
126
  help="Comma-separated file path patterns to filter by (wildcards supported)",
77
127
  )
128
+ @click.pass_context
78
129
  def query_command(
130
+ ctx: click.Context,
79
131
  query_text: str,
80
132
  url: str | None,
81
133
  top_k: int,
@@ -85,13 +137,16 @@ def query_command(
85
137
  json_output: bool,
86
138
  full: bool,
87
139
  scores: bool,
140
+ explain: bool,
88
141
  source_types: str | None,
89
142
  languages: str | None,
90
143
  file_paths: str | None,
91
144
  ) -> None:
92
145
  """Search indexed documents with natural language or keyword query."""
93
- # Get URL from config if not specified
94
- resolved_url = url or _get_default_url()
146
+ if url:
147
+ ctx.ensure_object(dict)
148
+ ctx.obj["base_url_override"] = url
149
+ ctx.obj["transport_hint"] = "http"
95
150
 
96
151
  # Parse comma-separated lists
97
152
  source_types_list = (
@@ -105,7 +160,7 @@ def query_command(
105
160
  )
106
161
 
107
162
  try:
108
- with DocServeClient(base_url=resolved_url) as client:
163
+ with open_client(ctx) as client:
109
164
  response = client.query(
110
165
  query_text=query_text,
111
166
  top_k=top_k,
@@ -115,6 +170,7 @@ def query_command(
115
170
  source_types=source_types_list,
116
171
  languages=languages_list,
117
172
  file_paths=file_paths_list,
173
+ explain=explain,
118
174
  )
119
175
 
120
176
  if json_output:
@@ -202,6 +258,10 @@ def query_command(
202
258
  )
203
259
  )
204
260
 
261
+ # Issue #159: optional structured explanation block.
262
+ if explain and result.explanation is not None:
263
+ _render_explanation(result.explanation)
264
+
205
265
  except ConnectionError as e:
206
266
  if json_output:
207
267
  import json
@@ -4,8 +4,8 @@ import click
4
4
  from rich.console import Console
5
5
  from rich.prompt import Confirm
6
6
 
7
- from ..client import ConnectionError, DocServeClient, ServerError
8
- from ..config import get_server_url
7
+ from ..client import ConnectionError, ServerError
8
+ from ..client.transport import open_client
9
9
  from ..diagnostics import doctor_hint_message
10
10
 
11
11
  console = Console()
@@ -25,13 +25,18 @@ console = Console()
25
25
  help="Skip confirmation prompt",
26
26
  )
27
27
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
28
- def reset_command(url: str | None, yes: bool, json_output: bool) -> None:
28
+ @click.pass_context
29
+ def reset_command(
30
+ ctx: click.Context, url: str | None, yes: bool, json_output: bool
31
+ ) -> None:
29
32
  """Reset the index by deleting all indexed documents.
30
33
 
31
34
  WARNING: This permanently removes all indexed content.
32
35
  """
33
- # Get URL from config if not specified
34
- resolved_url = url or get_server_url()
36
+ if url:
37
+ ctx.ensure_object(dict)
38
+ ctx.obj["base_url_override"] = url
39
+ ctx.obj["transport_hint"] = "http"
35
40
 
36
41
  # Confirm unless --yes flag provided
37
42
  if not yes and not json_output:
@@ -43,7 +48,7 @@ def reset_command(url: str | None, yes: bool, json_output: bool) -> None:
43
48
  return
44
49
 
45
50
  try:
46
- with DocServeClient(base_url=resolved_url) as client:
51
+ with open_client(ctx) as client:
47
52
  response = client.reset()
48
53
 
49
54
  if json_output:
@@ -25,6 +25,7 @@ STATE_DIR_NAME = ".agent-brain"
25
25
  LOCK_FILE = "agent-brain.lock"
26
26
  PID_FILE = "agent-brain.pid"
27
27
  RUNTIME_FILE = "runtime.json"
28
+ SOCKET_FILE_NAME = "agent-brain.sock"
28
29
 
29
30
 
30
31
  def read_config(state_dir: Path) -> dict[str, Any]:
@@ -105,6 +106,31 @@ def check_health(base_url: str, timeout: float = 3.0) -> bool:
105
106
  return False
106
107
 
107
108
 
109
+ def _probe_uds(socket_path: str, *, timeout_s: float = 2.0) -> bool:
110
+ """Probe ``socket_path`` with a UDS GET /health/ — True if 200.
111
+
112
+ Used by ``start --uds`` after the HTTP readiness probe to confirm
113
+ the UDS transport is actually live before advertising it in
114
+ runtime.json (Phase 7 reviewer #4). Local import keeps httpx out of
115
+ the start command's hot path when UDS isn't requested.
116
+ """
117
+ try:
118
+ import httpx
119
+ except ImportError:
120
+ return False
121
+ try:
122
+ transport = httpx.HTTPTransport(uds=str(socket_path))
123
+ with httpx.Client(
124
+ transport=transport,
125
+ base_url="http://agent-brain",
126
+ timeout=timeout_s,
127
+ ) as client:
128
+ response = client.get("/health/")
129
+ return bool(response.status_code == 200)
130
+ except Exception:
131
+ return False
132
+
133
+
108
134
  def find_available_port(host: str, start_port: int, end_port: int) -> int | None:
109
135
  """Find an available port in the given range."""
110
136
  for port in range(start_port, end_port + 1):
@@ -174,6 +200,16 @@ def update_registry(project_root: Path, state_dir: Path) -> None:
174
200
  is_flag=True,
175
201
  help="Enable strict mode: fail on critical provider configuration errors",
176
202
  )
203
+ @click.option(
204
+ "--uds",
205
+ is_flag=True,
206
+ help="Also bind a Unix Domain Socket alongside the HTTP listener.",
207
+ )
208
+ @click.option(
209
+ "--uds-only",
210
+ is_flag=True,
211
+ help="Bind only the Unix Domain Socket (no TCP listener). Implies --uds.",
212
+ )
177
213
  def start_command(
178
214
  path: str | None,
179
215
  host: str | None,
@@ -182,6 +218,8 @@ def start_command(
182
218
  timeout: int,
183
219
  json_output: bool,
184
220
  strict: bool,
221
+ uds: bool,
222
+ uds_only: bool,
185
223
  ) -> None:
186
224
  """Start an Agent Brain server for this project.
187
225
 
@@ -305,24 +343,39 @@ def start_command(
305
343
  if not json_output:
306
344
  console.print(f"[dim]Starting server on {base_url}...[/]")
307
345
 
308
- # Build server command
346
+ # Build server command. We invoke `agent_brain_server.api.main`'s
347
+ # Click CLI (not raw `python -m uvicorn`) so the server's `run()`
348
+ # function actually fires — it's the only place that branches on
349
+ # AGENT_BRAIN_UDS / _UDS_ONLY env vars to delegate to uds_bind
350
+ # (Phase 7 fix for reviewer finding A1). Behaviour for HTTP-only
351
+ # users is identical because run() ends up calling uvicorn.run()
352
+ # with the same args.
309
353
  server_cmd = [
310
354
  sys.executable,
311
355
  "-m",
312
- "uvicorn",
313
- "agent_brain_server.api.main:app",
356
+ "agent_brain_server.api.main",
314
357
  "--host",
315
358
  bind_host,
316
359
  "--port",
317
360
  str(bind_port),
318
361
  ]
319
362
 
363
+ # --uds-only implies --uds (plan §7)
364
+ enable_uds = uds or uds_only
365
+ socket_path = str(state_dir / SOCKET_FILE_NAME) if enable_uds else None
366
+
320
367
  # Set environment variables for server
321
368
  env = os.environ.copy()
322
369
  env["AGENT_BRAIN_PROJECT_ROOT"] = str(project_root)
323
370
  env["AGENT_BRAIN_STATE_DIR"] = str(state_dir)
324
371
  if strict:
325
372
  env["AGENT_BRAIN_STRICT_MODE"] = "true"
373
+ if enable_uds:
374
+ env["AGENT_BRAIN_UDS"] = "1"
375
+ assert socket_path is not None # for mypy
376
+ env["AGENT_BRAIN_UDS_PATH"] = socket_path
377
+ if uds_only:
378
+ env["AGENT_BRAIN_UDS_ONLY"] = "1"
326
379
 
327
380
  if foreground:
328
381
  # Write runtime state even in foreground mode so CLI can discover the URL
@@ -340,6 +393,7 @@ def start_command(
340
393
  "pid": os.getpid(), # Current PID (will be replaced by exec)
341
394
  "started_at": datetime.now(timezone.utc).isoformat(),
342
395
  "foreground": True, # Mark as foreground for cleanup detection
396
+ "socket_path": socket_path,
343
397
  }
344
398
  write_runtime(state_dir, runtime_state)
345
399
 
@@ -391,6 +445,7 @@ def start_command(
391
445
  "port": bind_port,
392
446
  "pid": process.pid,
393
447
  "started_at": datetime.now(timezone.utc).isoformat(),
448
+ "socket_path": socket_path,
394
449
  }
395
450
  write_runtime(state_dir, runtime_state)
396
451
 
@@ -409,6 +464,22 @@ def start_command(
409
464
  break
410
465
  time.sleep(0.5)
411
466
 
467
+ # If UDS was requested, probe the socket too and unset the
468
+ # runtime.json::socket_path field if it isn't actually live.
469
+ # Otherwise a runtime.json entry for socket_path misleads any
470
+ # client (MCP, CLI auto-mode) that prefers UDS (Phase 7
471
+ # reviewer #4).
472
+ if ready and enable_uds and socket_path:
473
+ if not _probe_uds(socket_path, timeout_s=2.0):
474
+ runtime_state["socket_path"] = None
475
+ write_runtime(state_dir, runtime_state)
476
+ if not json_output:
477
+ console.print(
478
+ "[yellow]UDS socket probe failed — "
479
+ "runtime.json::socket_path cleared. "
480
+ "Clients will use HTTP.[/]"
481
+ )
482
+
412
483
  if ready:
413
484
  if json_output:
414
485
  click.echo(
@@ -419,6 +490,7 @@ def start_command(
419
490
  "pid": process.pid,
420
491
  "project_root": str(project_root),
421
492
  "log_file": str(stdout_log),
493
+ "socket_path": runtime_state.get("socket_path"),
422
494
  },
423
495
  indent=2,
424
496
  )
@@ -5,8 +5,8 @@ from rich.console import Console
5
5
  from rich.panel import Panel
6
6
  from rich.table import Table
7
7
 
8
- from ..client import ConnectionError, DocServeClient, ServerError
9
- from ..config import get_server_url
8
+ from ..client import ConnectionError, ServerError
9
+ from ..client.transport import open_client
10
10
  from ..diagnostics import doctor_hint_message
11
11
 
12
12
  console = Console()
@@ -21,11 +21,19 @@ console = Console()
21
21
  )
22
22
  @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
23
23
  @click.option("--verbose", "-v", is_flag=True, help="Show additional detail")
24
- def status_command(url: str | None, json_output: bool, verbose: bool) -> None:
24
+ @click.pass_context
25
+ def status_command(
26
+ ctx: click.Context, url: str | None, json_output: bool, verbose: bool
27
+ ) -> None:
25
28
  """Check Agent Brain server status and health."""
26
- resolved_url = url or get_server_url()
29
+ # ``--url`` is a per-command HTTP override — promote it to the
30
+ # transport-selector context so ``open_client`` honors it.
31
+ if url:
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["base_url_override"] = url
34
+ ctx.obj["transport_hint"] = "http"
27
35
  try:
28
- with DocServeClient(base_url=resolved_url) as client:
36
+ with open_client(ctx) as client:
29
37
  health = client.health()
30
38
  indexing = client.status()
31
39
 
@@ -8,7 +8,7 @@ import logging
8
8
  import os
9
9
  import sys
10
10
  from pathlib import Path
11
- from typing import Any
11
+ from typing import Any, Literal
12
12
 
13
13
  import yaml
14
14
  from pydantic import BaseModel, Field
@@ -412,3 +412,75 @@ def get_server_url(config: AgentBrainConfig | None = None) -> str:
412
412
  config = load_config()
413
413
 
414
414
  return config.server.url
415
+
416
+
417
+ def resolve_transport(
418
+ *,
419
+ transport_hint: str | None = None,
420
+ base_url_override: str | None = None,
421
+ socket_path_override: Path | None = None,
422
+ config: AgentBrainConfig | None = None,
423
+ ) -> tuple[Literal["http", "uds"], str]:
424
+ """Resolve the active transport and its connection target.
425
+
426
+ Sibling to :func:`get_server_url` — shares the same precedence chain
427
+ for HTTP, layers UDS detection on top. See plan §4.4 and §12.3 #6.
428
+
429
+ Precedence:
430
+ 1. ``transport_hint`` argument (from CLI ``--transport`` flag)
431
+ 2. ``AGENT_BRAIN_TRANSPORT`` environment variable
432
+ 3. ``"auto"`` (try UDS first, fall back to HTTP)
433
+
434
+ For ``"uds"``: uses ``socket_path_override`` → ``AGENT_BRAIN_UDS_PATH``
435
+ env → ``agent_brain_uds.resolve_socket_path``. Validates with
436
+ :func:`agent_brain_uds.validate_socket` and raises on failure (so
437
+ an explicit ``--transport uds`` without a valid socket exits loudly,
438
+ per plan §12.3 #7).
439
+
440
+ For ``"http"``: uses ``base_url_override`` → :func:`get_server_url`.
441
+
442
+ For ``"auto"``: tries UDS; on any validation failure, falls back to
443
+ HTTP transparently.
444
+
445
+ Returns:
446
+ A ``(transport, target)`` tuple where ``transport`` is
447
+ ``"http"`` or ``"uds"`` and ``target`` is the URL or socket path
448
+ (as a string).
449
+ """
450
+ chosen = (
451
+ transport_hint or os.environ.get("AGENT_BRAIN_TRANSPORT") or "auto"
452
+ ).lower()
453
+
454
+ def _resolve_uds_target() -> str:
455
+ from agent_brain_uds import resolve_socket_path, validate_socket
456
+
457
+ if socket_path_override is not None:
458
+ path = Path(socket_path_override).expanduser()
459
+ elif env_path := os.environ.get("AGENT_BRAIN_UDS_PATH"):
460
+ path = Path(env_path).expanduser()
461
+ else:
462
+ path = resolve_socket_path(None)
463
+ validate_socket(path)
464
+ return str(path)
465
+
466
+ def _resolve_http_target() -> str:
467
+ if base_url_override:
468
+ return base_url_override
469
+ return get_server_url(config)
470
+
471
+ if chosen == "uds":
472
+ return ("uds", _resolve_uds_target())
473
+ if chosen == "http":
474
+ return ("http", _resolve_http_target())
475
+
476
+ # "auto" — try UDS, fall back to HTTP on any validation failure.
477
+ try:
478
+ from agent_brain_uds import AgentBrainUdsError
479
+
480
+ try:
481
+ return ("uds", _resolve_uds_target())
482
+ except (AgentBrainUdsError, OSError, FileNotFoundError):
483
+ return ("http", _resolve_http_target())
484
+ except ImportError:
485
+ # agent_brain_uds not installed — HTTP is the only option.
486
+ return ("http", _resolve_http_target())
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agent-brain-cli"
3
- version = "10.0.7"
3
+ version = "10.2.0"
4
4
  description = "Agent Brain CLI - Command-line interface for managing AI agent memory and knowledge retrieval"
5
5
  authors = ["Spillwave Solutions"]
6
6
  readme = "README.md"
@@ -27,7 +27,8 @@ httpx = "^0.28.0"
27
27
  rich = "^13.9.0"
28
28
  pyyaml = "^6.0.0"
29
29
  pydantic = "^2.10.0"
30
- agent-brain-rag = "^10.0.7"
30
+ agent-brain-rag = "^10.2.0"
31
+ agent-brain-uds = "^10.2.0"
31
32
 
32
33
  [tool.poetry.group.dev.dependencies]
33
34
  pytest = "^8.3.0"