agent-brain-cli 9.6.0__tar.gz → 10.0.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 (41) hide show
  1. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/PKG-INFO +2 -2
  2. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/__init__.py +1 -1
  3. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/cli.py +3 -0
  4. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/__init__.py +2 -0
  5. agent_brain_cli-10.0.0/agent_brain_cli/commands/doctor.py +86 -0
  6. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/index.py +2 -0
  7. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/init.py +1 -47
  8. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/jobs.py +2 -0
  9. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/query.py +2 -0
  10. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/reset.py +2 -0
  11. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/start.py +1 -32
  12. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/status.py +2 -0
  13. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/config.py +26 -11
  14. agent_brain_cli-10.0.0/agent_brain_cli/diagnostics.py +384 -0
  15. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/pyproject.toml +2 -2
  16. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/README.md +0 -0
  17. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/client/__init__.py +0 -0
  18. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/client/api_client.py +0 -0
  19. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/cache.py +0 -0
  20. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/config.py +0 -0
  21. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/folders.py +0 -0
  22. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/inject.py +0 -0
  23. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/install_agent.py +0 -0
  24. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/list_cmd.py +0 -0
  25. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/stop.py +0 -0
  26. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/types.py +0 -0
  27. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/commands/uninstall.py +0 -0
  28. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/config_migrate.py +0 -0
  29. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/config_schema.py +0 -0
  30. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/migration.py +0 -0
  31. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/__init__.py +0 -0
  32. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/claude_converter.py +0 -0
  33. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/codex_converter.py +0 -0
  34. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/converter_base.py +0 -0
  35. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/gemini_converter.py +0 -0
  36. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/opencode_converter.py +0 -0
  37. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/parser.py +0 -0
  38. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/skill_runtime_converter.py +0 -0
  39. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/tool_maps.py +0 -0
  40. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.0}/agent_brain_cli/runtime/types.py +0 -0
  41. {agent_brain_cli-9.6.0 → agent_brain_cli-10.0.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: 9.6.0
3
+ Version: 10.0.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,7 @@ 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 (>=9.6.0,<10.0.0)
18
+ Requires-Dist: agent-brain-rag (>=10.0.0,<11.0.0)
19
19
  Requires-Dist: click (>=8.1.0,<9.0.0)
20
20
  Requires-Dist: httpx (>=0.28.0,<0.29.0)
21
21
  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__ = "9.6.0"
3
+ __version__ = "10.0.0"
@@ -10,6 +10,7 @@ from . import __version__
10
10
  from .commands import (
11
11
  cache_group,
12
12
  config_group,
13
+ doctor_command,
13
14
  folders_group,
14
15
  index_command,
15
16
  init_command,
@@ -50,6 +51,7 @@ def cli() -> None:
50
51
  inject Index documents with content injection
51
52
  jobs View and manage job queue
52
53
  reset Clear all indexed documents
54
+ doctor Diagnose installation, configuration, and server state
53
55
 
54
56
  \b
55
57
  Cache Commands:
@@ -103,6 +105,7 @@ cli.add_command(types_group, name="types")
103
105
  cli.add_command(cache_group, name="cache")
104
106
  cli.add_command(uninstall_command, name="uninstall")
105
107
  cli.add_command(install_agent_command, name="install-agent")
108
+ cli.add_command(doctor_command, name="doctor")
106
109
 
107
110
 
108
111
  if __name__ == "__main__":
@@ -2,6 +2,7 @@
2
2
 
3
3
  from .cache import cache_group
4
4
  from .config import config_group
5
+ from .doctor import doctor_command
5
6
  from .folders import folders_group
6
7
  from .index import index_command
7
8
  from .init import init_command
@@ -20,6 +21,7 @@ from .uninstall import uninstall_command
20
21
  __all__ = [
21
22
  "cache_group",
22
23
  "config_group",
24
+ "doctor_command",
23
25
  "folders_group",
24
26
  "index_command",
25
27
  "inject_command",
@@ -0,0 +1,86 @@
1
+ """``agent-brain doctor`` — diagnose installation, configuration and server state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+
10
+ from agent_brain_cli.diagnostics import (
11
+ SEVERITY_FAIL,
12
+ SEVERITY_OK,
13
+ SEVERITY_WARN,
14
+ report_to_json,
15
+ run_doctor,
16
+ )
17
+
18
+ console = Console()
19
+
20
+
21
+ _STATUS_STYLE = {
22
+ SEVERITY_OK: ("green", "OK"),
23
+ SEVERITY_WARN: ("yellow", "WARN"),
24
+ SEVERITY_FAIL: ("red", "FAIL"),
25
+ }
26
+
27
+
28
+ @click.command("doctor")
29
+ @click.option(
30
+ "--url",
31
+ envvar="AGENT_BRAIN_URL",
32
+ default=None,
33
+ help="Server URL to probe (default: resolved from runtime.json or config).",
34
+ )
35
+ @click.option("--json", "json_output", is_flag=True, help="Emit machine-readable JSON.")
36
+ def doctor_command(url: str | None, json_output: bool) -> None:
37
+ """Diagnose your Agent Brain setup.
38
+
39
+ Inspects Python version, project init state, provider config, required
40
+ API keys, optional dependencies, .gitignore hygiene, and whether the
41
+ server is reachable. Exits non-zero on any critical failure so it can
42
+ be used in scripts (``agent-brain doctor || agent-brain init``).
43
+ """
44
+ report = run_doctor(server_url_override=url)
45
+
46
+ if json_output:
47
+ click.echo(report_to_json(report))
48
+ raise SystemExit(report.exit_code)
49
+
50
+ header_color = "green" if report.exit_code == 0 else "red"
51
+ console.print(
52
+ Panel(
53
+ (
54
+ f"[bold]Project root:[/] {report.project_root}\n"
55
+ f"[bold]State dir:[/] {report.state_dir} "
56
+ f"({'present' if report.state_dir_exists else 'missing'})\n"
57
+ f"[bold]Server URL:[/] {report.server_url}"
58
+ ),
59
+ title="Agent Brain Doctor",
60
+ border_style=header_color,
61
+ )
62
+ )
63
+
64
+ table = Table(show_header=True, header_style="bold cyan")
65
+ table.add_column("Check", style="dim")
66
+ table.add_column("Status")
67
+ table.add_column("Details", overflow="fold")
68
+
69
+ for check in report.checks:
70
+ style, label = _STATUS_STYLE.get(check.status, ("white", check.status.upper()))
71
+ body = check.message
72
+ if check.fix:
73
+ body = f"{body}\n[dim]→ {check.fix}[/]"
74
+ table.add_row(check.name, f"[{style}]{label}[/]", body)
75
+
76
+ console.print(table)
77
+
78
+ if report.exit_code != 0:
79
+ console.print(
80
+ "\n[red]Doctor reported critical issues.[/] "
81
+ "Fix the items above and re-run.",
82
+ )
83
+ else:
84
+ console.print("\n[green]All critical checks passed.[/]")
85
+
86
+ raise SystemExit(report.exit_code)
@@ -7,6 +7,7 @@ from rich.console import Console
7
7
 
8
8
  from ..client import ConnectionError, DocServeClient, ServerError
9
9
  from ..config import get_server_url
10
+ from ..diagnostics import doctor_hint_message
10
11
 
11
12
  console = Console()
12
13
 
@@ -184,6 +185,7 @@ def index_command(
184
185
  click.echo(json.dumps({"error": str(e)}))
185
186
  else:
186
187
  console.print(f"[red]Connection Error:[/] {e}")
188
+ console.print(f"[dim]{doctor_hint_message()}[/]")
187
189
  raise SystemExit(1) from e
188
190
 
189
191
  except ServerError as e:
@@ -7,6 +7,7 @@ import click
7
7
  from rich.console import Console
8
8
  from rich.panel import Panel
9
9
 
10
+ from agent_brain_cli.config import resolve_project_root
10
11
  from agent_brain_cli.migration import migrate_state_dir
11
12
  from agent_brain_cli.xdg_paths import migrate_legacy_paths
12
13
 
@@ -37,53 +38,6 @@ DEFAULT_CONFIG = {
37
38
  STATE_DIR_NAME = ".agent-brain"
38
39
 
39
40
 
40
- def resolve_project_root(start_path: Path | None = None) -> Path:
41
- """Resolve the canonical project root directory.
42
-
43
- Resolution order:
44
- 1. Git repository root (git rev-parse --show-toplevel)
45
- 2. Walk up looking for .claude/ directory
46
- 3. Walk up looking for pyproject.toml
47
- 4. Fall back to cwd
48
-
49
- Args:
50
- start_path: Starting path for resolution. Defaults to cwd.
51
-
52
- Returns:
53
- Resolved project root path.
54
- """
55
- import subprocess
56
-
57
- start = (start_path or Path.cwd()).resolve()
58
-
59
- # Try git root first
60
- try:
61
- result = subprocess.run(
62
- ["git", "rev-parse", "--show-toplevel"],
63
- capture_output=True,
64
- text=True,
65
- timeout=5,
66
- cwd=str(start),
67
- )
68
- if result.returncode == 0:
69
- return Path(result.stdout.strip()).resolve()
70
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
71
- pass
72
-
73
- # Walk up looking for markers
74
- current = start
75
- while current != current.parent:
76
- if (current / ".agent-brain").is_dir():
77
- return current
78
- if (current / ".claude").is_dir():
79
- return current
80
- if (current / "pyproject.toml").is_file():
81
- return current
82
- current = current.parent
83
-
84
- return start
85
-
86
-
87
41
  @click.command("init")
88
42
  @click.option(
89
43
  "--path",
@@ -10,6 +10,7 @@ from rich.table import Table
10
10
 
11
11
  from ..client import ConnectionError, DocServeClient, ServerError
12
12
  from ..config import get_server_url
13
+ from ..diagnostics import doctor_hint_message
13
14
 
14
15
  console = Console()
15
16
 
@@ -310,6 +311,7 @@ def jobs_command(
310
311
  click.echo(json.dumps({"error": str(e)}))
311
312
  else:
312
313
  console.print(f"[red]Connection Error:[/] {e}")
314
+ console.print(f"[dim]{doctor_hint_message()}[/]")
313
315
  raise SystemExit(1) from e
314
316
 
315
317
  except ServerError as e:
@@ -7,6 +7,7 @@ from rich.text import Text
7
7
 
8
8
  from ..client import ConnectionError, DocServeClient, ServerError
9
9
  from ..config import get_server_url
10
+ from ..diagnostics import doctor_hint_message
10
11
 
11
12
  console = Console()
12
13
 
@@ -208,6 +209,7 @@ def query_command(
208
209
  click.echo(json.dumps({"error": str(e)}))
209
210
  else:
210
211
  console.print(f"[red]Connection Error:[/] {e}")
212
+ console.print(f"[dim]{doctor_hint_message()}[/]")
211
213
  raise SystemExit(1) from e
212
214
 
213
215
  except ServerError as e:
@@ -6,6 +6,7 @@ from rich.prompt import Confirm
6
6
 
7
7
  from ..client import ConnectionError, DocServeClient, ServerError
8
8
  from ..config import get_server_url
9
+ from ..diagnostics import doctor_hint_message
9
10
 
10
11
  console = Console()
11
12
 
@@ -67,6 +68,7 @@ def reset_command(url: str | None, yes: bool, json_output: bool) -> None:
67
68
  click.echo(json.dumps({"error": str(e)}))
68
69
  else:
69
70
  console.print(f"[red]Connection Error:[/] {e}")
71
+ console.print(f"[dim]{doctor_hint_message()}[/]")
70
72
  raise SystemExit(1) from e
71
73
 
72
74
  except ServerError as e:
@@ -15,6 +15,7 @@ import click
15
15
  from rich.console import Console
16
16
  from rich.panel import Panel
17
17
 
18
+ from agent_brain_cli.config import resolve_project_root
18
19
  from agent_brain_cli.migration import resolve_state_dir_with_fallback
19
20
  from agent_brain_cli.xdg_paths import get_xdg_state_dir, migrate_legacy_paths
20
21
 
@@ -26,38 +27,6 @@ PID_FILE = "agent-brain.pid"
26
27
  RUNTIME_FILE = "runtime.json"
27
28
 
28
29
 
29
- def resolve_project_root(start_path: Path | None = None) -> Path:
30
- """Resolve the canonical project root directory."""
31
- start = (start_path or Path.cwd()).resolve()
32
-
33
- # Try git root first
34
- try:
35
- result = subprocess.run(
36
- ["git", "rev-parse", "--show-toplevel"],
37
- capture_output=True,
38
- text=True,
39
- timeout=5,
40
- cwd=str(start),
41
- )
42
- if result.returncode == 0:
43
- return Path(result.stdout.strip()).resolve()
44
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
45
- pass
46
-
47
- # Walk up looking for markers
48
- current = start
49
- while current != current.parent:
50
- if (current / ".agent-brain").is_dir():
51
- return current
52
- if (current / ".claude").is_dir():
53
- return current
54
- if (current / "pyproject.toml").is_file():
55
- return current
56
- current = current.parent
57
-
58
- return start
59
-
60
-
61
30
  def read_config(state_dir: Path) -> dict[str, Any]:
62
31
  """Read configuration from state directory."""
63
32
  config_path = state_dir / "config.json"
@@ -7,6 +7,7 @@ from rich.table import Table
7
7
 
8
8
  from ..client import ConnectionError, DocServeClient, ServerError
9
9
  from ..config import get_server_url
10
+ from ..diagnostics import doctor_hint_message
10
11
 
11
12
  console = Console()
12
13
 
@@ -151,6 +152,7 @@ def status_command(url: str | None, json_output: bool, verbose: bool) -> None:
151
152
  click.echo(json.dumps({"error": str(e)}))
152
153
  else:
153
154
  console.print(f"[red]Connection Error:[/] {e}")
155
+ console.print(f"[dim]{doctor_hint_message()}[/]")
154
156
  raise SystemExit(1) from e
155
157
 
156
158
  except ServerError as e:
@@ -251,25 +251,38 @@ def load_config(start_path: Path | None = None) -> AgentBrainConfig:
251
251
  return config
252
252
 
253
253
 
254
- def _find_project_root(start_path: Path | None = None) -> Path:
254
+ def resolve_project_root(start_path: Path | None = None) -> Path:
255
255
  """Find the project root by looking for markers.
256
256
 
257
- Walks up from start_path looking for:
258
- 1. Git repository root
259
- 2. .claude/ directory
260
- 3. pyproject.toml file
257
+ Resolution order (first match wins):
258
+ 1. Walk up from ``start_path`` looking for ``.agent-brain/`` — this lets a
259
+ sub-project inside a mono-repo keep its own state dir and not get
260
+ pulled to the git top-level (issues #124, #128).
261
+ 2. Walk up looking for legacy ``.claude/agent-brain/``.
262
+ 3. Git repository root (``git rev-parse --show-toplevel``).
263
+ 4. Walk up looking for ``.claude/`` or ``pyproject.toml``.
264
+ 5. Fall back to ``start_path``.
261
265
 
262
266
  Args:
263
267
  start_path: Starting directory. Defaults to cwd.
264
268
 
265
269
  Returns:
266
- Project root path or start_path if no markers found.
270
+ Project root path.
267
271
  """
268
272
  import subprocess
269
273
 
270
274
  start = (start_path or Path.cwd()).resolve()
271
275
 
272
- # Try git root first
276
+ # 1 & 2. Prefer a local state dir over git root so nested projects work.
277
+ current = start
278
+ while current != current.parent:
279
+ if (current / STATE_DIR_NAME).is_dir():
280
+ return current
281
+ if (current / LEGACY_STATE_DIR_NAME).is_dir():
282
+ return current
283
+ current = current.parent
284
+
285
+ # 3. Git root next — useful when this is the first time the user runs init.
273
286
  try:
274
287
  result = subprocess.run(
275
288
  ["git", "rev-parse", "--show-toplevel"],
@@ -283,11 +296,9 @@ def _find_project_root(start_path: Path | None = None) -> Path:
283
296
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
284
297
  pass
285
298
 
286
- # Walk up looking for markers
299
+ # 4. Other markers.
287
300
  current = start
288
301
  while current != current.parent:
289
- if (current / ".agent-brain").is_dir():
290
- return current
291
302
  if (current / ".claude").is_dir():
292
303
  return current
293
304
  if (current / "pyproject.toml").is_file():
@@ -297,6 +308,10 @@ def _find_project_root(start_path: Path | None = None) -> Path:
297
308
  return start
298
309
 
299
310
 
311
+ # Backwards-compatible alias for any external callers.
312
+ _find_project_root = resolve_project_root
313
+
314
+
300
315
  def get_state_dir(
301
316
  config: AgentBrainConfig | None = None,
302
317
  project_root: Path | None = None,
@@ -318,7 +333,7 @@ def get_state_dir(
318
333
  """
319
334
  # 1. Auto-detect project root and check for existing state dir
320
335
  if project_root is None:
321
- project_root = _find_project_root()
336
+ project_root = resolve_project_root()
322
337
 
323
338
  # Check new path first, then legacy
324
339
  new_state_dir = project_root / STATE_DIR_NAME
@@ -0,0 +1,384 @@
1
+ """Shared diagnostics helpers for the Agent Brain CLI.
2
+
3
+ The functions here power both the ``agent-brain doctor`` command and the
4
+ "tip: run doctor" hint that appears when a command can't reach the server.
5
+ Keeping the logic in one place means the hint and the diagnosis can never
6
+ drift out of sync.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import socket
16
+ import sys
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.error import URLError
21
+ from urllib.request import Request, urlopen
22
+
23
+ from agent_brain_cli.config import (
24
+ LEGACY_STATE_DIR_NAME,
25
+ STATE_DIR_NAME,
26
+ get_server_url,
27
+ load_config,
28
+ resolve_project_root,
29
+ )
30
+
31
+ #: Severity returned by every diagnostic check.
32
+ SEVERITY_OK = "ok"
33
+ SEVERITY_WARN = "warn"
34
+ SEVERITY_FAIL = "fail"
35
+
36
+ DOCTOR_HINT = "Tip: run `agent-brain doctor` to diagnose your setup."
37
+
38
+
39
+ @dataclass
40
+ class CheckResult:
41
+ """One row in the doctor output."""
42
+
43
+ name: str
44
+ status: str # ok | warn | fail
45
+ message: str
46
+ fix: str | None = None
47
+ details: dict[str, Any] = field(default_factory=dict)
48
+
49
+
50
+ @dataclass
51
+ class DoctorReport:
52
+ """The full diagnostic snapshot."""
53
+
54
+ project_root: str
55
+ state_dir: str
56
+ state_dir_exists: bool
57
+ runtime_file: str | None
58
+ server_url: str
59
+ checks: list[CheckResult]
60
+
61
+ @property
62
+ def exit_code(self) -> int:
63
+ """Non-zero when any critical check failed."""
64
+ return 1 if any(c.status == SEVERITY_FAIL for c in self.checks) else 0
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ data = asdict(self)
68
+ data["exit_code"] = self.exit_code
69
+ return data
70
+
71
+
72
+ def _check_python() -> CheckResult:
73
+ major, minor = sys.version_info[:2]
74
+ version = f"{major}.{minor}.{sys.version_info.micro}"
75
+ if (major, minor) >= (3, 10):
76
+ return CheckResult(
77
+ "python_version",
78
+ SEVERITY_OK,
79
+ f"Python {version}",
80
+ details={"version": version},
81
+ )
82
+ return CheckResult(
83
+ "python_version",
84
+ SEVERITY_FAIL,
85
+ f"Python {version} — Agent Brain requires 3.10+",
86
+ fix="Upgrade to Python 3.10 or newer.",
87
+ details={"version": version},
88
+ )
89
+
90
+
91
+ def _check_project_init(project_root: Path, state_dir: Path) -> CheckResult:
92
+ config_path = state_dir / "config.json"
93
+ if config_path.exists():
94
+ return CheckResult(
95
+ "project_initialized",
96
+ SEVERITY_OK,
97
+ f"Project initialized at {state_dir}",
98
+ details={"state_dir": str(state_dir)},
99
+ )
100
+ return CheckResult(
101
+ "project_initialized",
102
+ SEVERITY_FAIL,
103
+ f"No {STATE_DIR_NAME}/config.json under {project_root}",
104
+ fix="Run `agent-brain init` in your project directory.",
105
+ details={
106
+ "project_root": str(project_root),
107
+ "expected_path": str(config_path),
108
+ },
109
+ )
110
+
111
+
112
+ def _check_provider_config(state_dir: Path) -> CheckResult:
113
+ yaml_path = state_dir / "config.yaml"
114
+ try:
115
+ cfg = load_config()
116
+ except Exception as exc: # pragma: no cover — pydantic noise
117
+ return CheckResult(
118
+ "provider_config",
119
+ SEVERITY_FAIL,
120
+ f"Failed to load config.yaml: {exc}",
121
+ fix=f"Fix or delete {yaml_path} and re-run `agent-brain doctor`.",
122
+ )
123
+
124
+ return CheckResult(
125
+ "provider_config",
126
+ SEVERITY_OK,
127
+ (
128
+ f"embedding={cfg.embedding.provider}:{cfg.embedding.model}, "
129
+ f"summarization={cfg.summarization.provider}:{cfg.summarization.model}"
130
+ ),
131
+ details={
132
+ "config_path": str(yaml_path) if yaml_path.exists() else None,
133
+ "embedding_provider": cfg.embedding.provider,
134
+ "embedding_model": cfg.embedding.model,
135
+ "summarization_provider": cfg.summarization.provider,
136
+ "summarization_model": cfg.summarization.model,
137
+ },
138
+ )
139
+
140
+
141
+ _PROVIDER_KEY_ENV: dict[str, str] = {
142
+ "openai": "OPENAI_API_KEY",
143
+ "anthropic": "ANTHROPIC_API_KEY",
144
+ "claude": "ANTHROPIC_API_KEY",
145
+ "cohere": "COHERE_API_KEY",
146
+ "gemini": "GEMINI_API_KEY",
147
+ "google": "GEMINI_API_KEY",
148
+ "grok": "XAI_API_KEY",
149
+ }
150
+
151
+
152
+ def _check_api_keys() -> list[CheckResult]:
153
+ try:
154
+ cfg = load_config()
155
+ except Exception: # pragma: no cover
156
+ return []
157
+
158
+ results: list[CheckResult] = []
159
+ for label, provider, model in (
160
+ ("embedding", cfg.embedding.provider, cfg.embedding.model),
161
+ ("summarization", cfg.summarization.provider, cfg.summarization.model),
162
+ ):
163
+ if provider == "ollama":
164
+ continue
165
+ env_name = (
166
+ cfg.embedding.api_key_env
167
+ if label == "embedding"
168
+ else cfg.summarization.api_key_env
169
+ ) or _PROVIDER_KEY_ENV.get(provider.lower())
170
+ if not env_name:
171
+ continue
172
+ present = bool(os.environ.get(env_name))
173
+ results.append(
174
+ CheckResult(
175
+ f"api_key_{label}",
176
+ SEVERITY_OK if present else SEVERITY_FAIL,
177
+ (
178
+ f"{env_name} is set"
179
+ if present
180
+ else f"{env_name} is not set (required by {provider})"
181
+ ),
182
+ fix=(
183
+ None
184
+ if present
185
+ else f"export {env_name}=… then re-run `agent-brain doctor`."
186
+ ),
187
+ details={
188
+ "provider": provider,
189
+ "model": model,
190
+ "env_var": env_name,
191
+ "present": present,
192
+ },
193
+ )
194
+ )
195
+ return results
196
+
197
+
198
+ def _is_listening(host: str, port: int, timeout: float = 0.5) -> bool:
199
+ try:
200
+ with socket.create_connection((host, port), timeout=timeout):
201
+ return True
202
+ except (OSError, ConnectionRefusedError):
203
+ return False
204
+
205
+
206
+ def _check_server(server_url: str, runtime_file: Path | None) -> CheckResult:
207
+ if runtime_file and not runtime_file.exists():
208
+ return CheckResult(
209
+ "server_reachable",
210
+ SEVERITY_WARN,
211
+ (
212
+ f"No runtime.json at {runtime_file} — server is probably not "
213
+ "running for this project."
214
+ ),
215
+ fix="Run `agent-brain start` to launch the server.",
216
+ details={
217
+ "runtime_file": str(runtime_file),
218
+ "server_url": server_url,
219
+ },
220
+ )
221
+
222
+ try:
223
+ req = Request(server_url.rstrip("/") + "/health")
224
+ with urlopen(req, timeout=3) as resp: # noqa: S310 — local URL
225
+ body = resp.read().decode("utf-8", errors="replace")
226
+ return CheckResult(
227
+ "server_reachable",
228
+ SEVERITY_OK,
229
+ f"Server responded at {server_url}",
230
+ details={"server_url": server_url, "response_preview": body[:120]},
231
+ )
232
+ except URLError as exc:
233
+ return CheckResult(
234
+ "server_reachable",
235
+ SEVERITY_FAIL,
236
+ f"Cannot reach server at {server_url}: {exc.reason}",
237
+ fix="Start it with `agent-brain start` (or pass --url).",
238
+ details={"server_url": server_url, "error": str(exc.reason)},
239
+ )
240
+ except Exception as exc: # noqa: BLE001
241
+ return CheckResult(
242
+ "server_reachable",
243
+ SEVERITY_FAIL,
244
+ f"Error contacting server at {server_url}: {exc}",
245
+ fix="Start it with `agent-brain start` (or pass --url).",
246
+ details={"server_url": server_url, "error": str(exc)},
247
+ )
248
+
249
+
250
+ def _check_optional_dep(provider: str, module_name: str, extra: str) -> CheckResult:
251
+ """Report on an optional Python package that a chosen provider needs."""
252
+ if shutil.which("python3"):
253
+ # We import in-process so test mocks of installed packages work.
254
+ try:
255
+ __import__(module_name)
256
+ return CheckResult(
257
+ f"optional_dep_{module_name}",
258
+ SEVERITY_OK,
259
+ f"{module_name} is installed ({provider} provider)",
260
+ details={"module": module_name, "provider": provider},
261
+ )
262
+ except ImportError:
263
+ return CheckResult(
264
+ f"optional_dep_{module_name}",
265
+ SEVERITY_FAIL,
266
+ (
267
+ f"{provider} provider selected but {module_name} is not "
268
+ "installed."
269
+ ),
270
+ fix=f"pip install 'agent-brain-rag[{extra}]'",
271
+ details={
272
+ "module": module_name,
273
+ "provider": provider,
274
+ "extras_install": extra,
275
+ },
276
+ )
277
+ return CheckResult(
278
+ f"optional_dep_{module_name}",
279
+ SEVERITY_WARN,
280
+ "Could not run Python interpreter to verify imports.",
281
+ )
282
+
283
+
284
+ def _check_gitignore(project_root: Path) -> CheckResult:
285
+ gi = project_root / ".gitignore"
286
+ if not gi.exists():
287
+ return CheckResult(
288
+ "gitignore_state_dir",
289
+ SEVERITY_WARN,
290
+ f"No .gitignore at {project_root} — {STATE_DIR_NAME}/ may get committed.",
291
+ fix=f"Add `{STATE_DIR_NAME}/` to .gitignore.",
292
+ )
293
+ try:
294
+ lines = {line.strip() for line in gi.read_text().splitlines()}
295
+ except OSError:
296
+ return CheckResult(
297
+ "gitignore_state_dir",
298
+ SEVERITY_WARN,
299
+ f"Could not read {gi}.",
300
+ )
301
+ if any(entry in lines for entry in (STATE_DIR_NAME, f"{STATE_DIR_NAME}/")):
302
+ return CheckResult(
303
+ "gitignore_state_dir",
304
+ SEVERITY_OK,
305
+ f"{STATE_DIR_NAME}/ is in .gitignore",
306
+ )
307
+ return CheckResult(
308
+ "gitignore_state_dir",
309
+ SEVERITY_WARN,
310
+ f"{STATE_DIR_NAME}/ is not in .gitignore — index data may get committed.",
311
+ fix=f"Add `{STATE_DIR_NAME}/` to .gitignore.",
312
+ )
313
+
314
+
315
+ def run_doctor(server_url_override: str | None = None) -> DoctorReport:
316
+ """Run every check and return a structured report."""
317
+ project_root = resolve_project_root()
318
+ state_dir = project_root / STATE_DIR_NAME
319
+ runtime_file: Path | None
320
+ if state_dir.exists():
321
+ runtime_file = state_dir / "runtime.json"
322
+ else:
323
+ legacy = project_root / LEGACY_STATE_DIR_NAME
324
+ runtime_file = legacy / "runtime.json" if legacy.exists() else None
325
+
326
+ server_url = server_url_override or get_server_url()
327
+
328
+ checks: list[CheckResult] = []
329
+ checks.append(_check_python())
330
+ checks.append(_check_project_init(project_root, state_dir))
331
+ checks.append(_check_provider_config(state_dir))
332
+ checks.extend(_check_api_keys())
333
+
334
+ # Optional deps that surface common install failures (issues #122/#125/#129).
335
+ try:
336
+ cfg = load_config()
337
+ except Exception: # pragma: no cover
338
+ cfg = None
339
+ if cfg and cfg.embedding.provider.lower() == "cohere":
340
+ checks.append(_check_optional_dep("cohere", "cohere", "cohere"))
341
+ checks.append(_check_gitignore(project_root))
342
+
343
+ checks.append(_check_server(server_url, runtime_file))
344
+
345
+ return DoctorReport(
346
+ project_root=str(project_root),
347
+ state_dir=str(state_dir),
348
+ state_dir_exists=state_dir.exists(),
349
+ runtime_file=str(runtime_file) if runtime_file else None,
350
+ server_url=server_url,
351
+ checks=checks,
352
+ )
353
+
354
+
355
+ def doctor_hint_message(project_root: Path | None = None) -> str:
356
+ """Suggest the doctor command — and call out the most likely setup issue.
357
+
358
+ When ``runtime.json`` is missing, the user almost certainly hasn't run
359
+ ``agent-brain init && agent-brain start`` in this directory. Saying so
360
+ is more useful than the generic "connection refused".
361
+ """
362
+ root = project_root or resolve_project_root()
363
+ state_dir = root / STATE_DIR_NAME
364
+ runtime_file = state_dir / "runtime.json"
365
+ if not runtime_file.exists():
366
+ return (
367
+ "Tip: no `.agent-brain/runtime.json` found under "
368
+ f"{root}. Run `agent-brain init` and `agent-brain start` here "
369
+ "first, or run `agent-brain doctor` to diagnose."
370
+ )
371
+ return DOCTOR_HINT
372
+
373
+
374
+ def report_to_json(report: DoctorReport) -> str:
375
+ return json.dumps(report.to_dict(), indent=2)
376
+
377
+
378
+ def env_snapshot() -> dict[str, Any]:
379
+ """Lightweight environment summary used in JSON output."""
380
+ return {
381
+ "platform": platform.platform(),
382
+ "python": platform.python_version(),
383
+ "cwd": str(Path.cwd()),
384
+ }
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agent-brain-cli"
3
- version = "9.6.0"
3
+ version = "10.0.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,7 @@ 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 = "^9.6.0"
30
+ agent-brain-rag = "^10.0.0"
31
31
 
32
32
  [tool.poetry.group.dev.dependencies]
33
33
  pytest = "^8.3.0"