agent-brain-cli 10.0.3__tar.gz → 10.0.5__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-10.0.3 → agent_brain_cli-10.0.5}/PKG-INFO +2 -2
  2. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/__init__.py +1 -1
  3. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/doctor.py +38 -2
  4. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/jobs.py +31 -7
  5. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/stop.py +1 -33
  6. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/config.py +23 -10
  7. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/diagnostics.py +149 -8
  8. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/pyproject.toml +2 -2
  9. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/README.md +0 -0
  10. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/cli.py +0 -0
  11. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/client/__init__.py +0 -0
  12. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/client/api_client.py +0 -0
  13. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/__init__.py +0 -0
  14. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/cache.py +0 -0
  15. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/config.py +0 -0
  16. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/folders.py +0 -0
  17. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/index.py +0 -0
  18. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/init.py +0 -0
  19. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/inject.py +0 -0
  20. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/install_agent.py +0 -0
  21. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/list_cmd.py +0 -0
  22. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/query.py +0 -0
  23. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/reset.py +0 -0
  24. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/start.py +0 -0
  25. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/status.py +0 -0
  26. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/types.py +0 -0
  27. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/commands/uninstall.py +0 -0
  28. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/config_migrate.py +0 -0
  29. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/config_schema.py +0 -0
  30. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/migration.py +0 -0
  31. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/__init__.py +0 -0
  32. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/claude_converter.py +0 -0
  33. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/codex_converter.py +0 -0
  34. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/converter_base.py +0 -0
  35. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/gemini_converter.py +0 -0
  36. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/opencode_converter.py +0 -0
  37. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/parser.py +0 -0
  38. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/skill_runtime_converter.py +0 -0
  39. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/tool_maps.py +0 -0
  40. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/agent_brain_cli/runtime/types.py +0 -0
  41. {agent_brain_cli-10.0.3 → agent_brain_cli-10.0.5}/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.3
3
+ Version: 10.0.5
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 (>=10.0.3,<11.0.0)
18
+ Requires-Dist: agent-brain-rag (>=10.0.5,<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__ = "10.0.3"
3
+ __version__ = "10.0.5"
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+
5
7
  import click
6
8
  from rich.console import Console
7
9
  from rich.panel import Panel
@@ -11,6 +13,7 @@ from agent_brain_cli.diagnostics import (
11
13
  SEVERITY_FAIL,
12
14
  SEVERITY_OK,
13
15
  SEVERITY_WARN,
16
+ apply_safe_fixes,
14
17
  report_to_json,
15
18
  run_doctor,
16
19
  )
@@ -33,18 +36,40 @@ _STATUS_STYLE = {
33
36
  help="Server URL to probe (default: resolved from runtime.json or config).",
34
37
  )
35
38
  @click.option("--json", "json_output", is_flag=True, help="Emit machine-readable JSON.")
36
- def doctor_command(url: str | None, json_output: bool) -> None:
39
+ @click.option(
40
+ "--fix",
41
+ "apply_fixes",
42
+ is_flag=True,
43
+ help=(
44
+ "Apply safe, idempotent, offline fixes (add .agent-brain/ to .gitignore, "
45
+ "create state dir + stub config.json). Will not touch API keys, network, "
46
+ "or user code. Re-runs the report after fixing."
47
+ ),
48
+ )
49
+ def doctor_command(url: str | None, json_output: bool, apply_fixes: bool) -> None:
37
50
  """Diagnose your Agent Brain setup.
38
51
 
39
52
  Inspects Python version, project init state, provider config, required
40
53
  API keys, optional dependencies, .gitignore hygiene, and whether the
41
54
  server is reachable. Exits non-zero on any critical failure so it can
42
55
  be used in scripts (``agent-brain doctor || agent-brain init``).
56
+
57
+ Pass ``--fix`` to auto-apply the safe subset of remediations and re-run.
43
58
  """
44
59
  report = run_doctor(server_url_override=url)
45
60
 
61
+ fix_actions: list[str] = []
62
+ if apply_fixes:
63
+ fix_actions = apply_safe_fixes(report)
64
+ if fix_actions:
65
+ # Re-run so the printed report reflects the fixed state.
66
+ report = run_doctor(server_url_override=url)
67
+
46
68
  if json_output:
47
- click.echo(report_to_json(report))
69
+ payload = json.loads(report_to_json(report))
70
+ if apply_fixes:
71
+ payload["applied_fixes"] = fix_actions
72
+ click.echo(json.dumps(payload, indent=2))
48
73
  raise SystemExit(report.exit_code)
49
74
 
50
75
  header_color = "green" if report.exit_code == 0 else "red"
@@ -75,6 +100,17 @@ def doctor_command(url: str | None, json_output: bool) -> None:
75
100
 
76
101
  console.print(table)
77
102
 
103
+ if apply_fixes:
104
+ if fix_actions:
105
+ console.print("\n[cyan]Applied safe fixes:[/]")
106
+ for action in fix_actions:
107
+ console.print(f" • {action}")
108
+ else:
109
+ console.print(
110
+ "\n[dim]No safe fixes applied (nothing actionable, or all "
111
+ "checks already passing).[/]"
112
+ )
113
+
78
114
  if report.exit_code != 0:
79
115
  console.print(
80
116
  "\n[red]Doctor reported critical issues.[/] "
@@ -25,13 +25,32 @@ def _format_timestamp(ts: str | None) -> str:
25
25
  return ts
26
26
 
27
27
 
28
- def _format_progress(progress: float | None, total: int | None) -> str:
29
- """Format progress for display."""
28
+ def _format_progress(
29
+ progress: float | int | dict[str, Any] | None,
30
+ total: int | None,
31
+ ) -> str:
32
+ """Format progress for display.
33
+
34
+ Copes with both the legacy float shape and the structured ``JobProgress``
35
+ dict the server emits today (#150). Falls back to a string coercion for
36
+ unknown types so the CLI never raises.
37
+ """
30
38
  if progress is None:
31
39
  return "-"
32
- if total:
33
- return f"{progress:.1f}% ({total} files)"
34
- return f"{progress:.1f}%"
40
+ if isinstance(progress, dict):
41
+ pct = progress.get("percent_complete")
42
+ if isinstance(pct, (int, float)):
43
+ files_total = progress.get("files_total") or 0
44
+ files_done = progress.get("files_processed") or 0
45
+ if files_total:
46
+ return f"{pct:.1f}% ({files_done}/{files_total} files)"
47
+ return f"{pct:.1f}%"
48
+ return ", ".join(f"{k}={v}" for k, v in progress.items())
49
+ if isinstance(progress, (int, float)):
50
+ if total:
51
+ return f"{progress:.1f}% ({total} files)"
52
+ return f"{progress:.1f}%"
53
+ return str(progress)
35
54
 
36
55
 
37
56
  def _get_status_style(status: str) -> str:
@@ -121,12 +140,17 @@ def _create_job_detail_panel(job: dict[str, Any]) -> Panel:
121
140
  if (progress := job.get("progress_percent", job.get("progress"))) is not None:
122
141
  total = job.get("total_files", 0)
123
142
  processed = job.get("processed_files", 0)
124
- if total:
143
+ # #150: server emits structured JobProgress dicts in addition to floats.
144
+ # Defer formatting to _format_progress so list-view and detail-view
145
+ # never diverge on type handling.
146
+ if isinstance(progress, (int, float)) and total:
125
147
  lines.append(
126
148
  f"[bold]Progress:[/] {progress:.1f}% ({processed}/{total} files)"
127
149
  )
128
150
  else:
129
- lines.append(f"[bold]Progress:[/] {progress:.1f}%")
151
+ lines.append(
152
+ f"[bold]Progress:[/] {_format_progress(progress, total or None)}"
153
+ )
130
154
 
131
155
  if enqueued := job.get("enqueued_at", job.get("created_at")):
132
156
  lines.append(f"[bold]Enqueued:[/] {_format_timestamp(enqueued)}")
@@ -3,7 +3,6 @@
3
3
  import json
4
4
  import os
5
5
  import signal
6
- import subprocess
7
6
  import time
8
7
  from pathlib import Path
9
8
  from typing import Any
@@ -12,6 +11,7 @@ from urllib.request import Request, urlopen
12
11
  import click
13
12
  from rich.console import Console
14
13
 
14
+ from agent_brain_cli.config import resolve_project_root
15
15
  from agent_brain_cli.migration import resolve_state_dir_with_fallback
16
16
  from agent_brain_cli.xdg_paths import get_registry_path
17
17
 
@@ -23,38 +23,6 @@ PID_FILE = "agent-brain.pid"
23
23
  RUNTIME_FILE = "runtime.json"
24
24
 
25
25
 
26
- def resolve_project_root(start_path: Path | None = None) -> Path:
27
- """Resolve the canonical project root directory."""
28
- start = (start_path or Path.cwd()).resolve()
29
-
30
- # Try git root first
31
- try:
32
- result = subprocess.run(
33
- ["git", "rev-parse", "--show-toplevel"],
34
- capture_output=True,
35
- text=True,
36
- timeout=5,
37
- cwd=str(start),
38
- )
39
- if result.returncode == 0:
40
- return Path(result.stdout.strip()).resolve()
41
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
42
- pass
43
-
44
- # Walk up looking for markers
45
- current = start
46
- while current != current.parent:
47
- if (current / ".agent-brain").is_dir():
48
- return current
49
- if (current / ".claude").is_dir():
50
- return current
51
- if (current / "pyproject.toml").is_file():
52
- return current
53
- current = current.parent
54
-
55
- return start
56
-
57
-
58
26
  def read_runtime(state_dir: Path) -> dict[str, Any] | None:
59
27
  """Read runtime state from state directory."""
60
28
  runtime_path = state_dir / RUNTIME_FILE
@@ -254,6 +254,20 @@ def load_config(start_path: Path | None = None) -> AgentBrainConfig:
254
254
  def resolve_project_root(start_path: Path | None = None) -> Path:
255
255
  """Find the project root by looking for markers.
256
256
 
257
+ Thin wrapper around :func:`resolve_project_root_with_strategy` that drops
258
+ the strategy label for callers that only need the path.
259
+ """
260
+ return resolve_project_root_with_strategy(start_path)[0]
261
+
262
+
263
+ def resolve_project_root_with_strategy(
264
+ start_path: Path | None = None,
265
+ ) -> tuple[Path, str]:
266
+ """Find the project root and report *which* rule matched.
267
+
268
+ Used by ``agent-brain doctor`` to explain why a given directory was
269
+ selected (issue #146).
270
+
257
271
  Resolution order (first match wins):
258
272
  1. Walk up from ``start_path`` looking for ``.agent-brain/`` — this lets a
259
273
  sub-project inside a mono-repo keep its own state dir and not get
@@ -263,11 +277,10 @@ def resolve_project_root(start_path: Path | None = None) -> Path:
263
277
  4. Walk up looking for ``.claude/`` or ``pyproject.toml``.
264
278
  5. Fall back to ``start_path``.
265
279
 
266
- Args:
267
- start_path: Starting directory. Defaults to cwd.
268
-
269
280
  Returns:
270
- Project root path.
281
+ ``(root, strategy)`` where ``strategy`` is one of
282
+ ``"agent_brain_dir"``, ``"legacy_claude_dir"``, ``"git_root"``,
283
+ ``"claude_dir"``, ``"pyproject"``, ``"cwd_fallback"``.
271
284
  """
272
285
  import subprocess
273
286
 
@@ -277,9 +290,9 @@ def resolve_project_root(start_path: Path | None = None) -> Path:
277
290
  current = start
278
291
  while current != current.parent:
279
292
  if (current / STATE_DIR_NAME).is_dir():
280
- return current
293
+ return current, "agent_brain_dir"
281
294
  if (current / LEGACY_STATE_DIR_NAME).is_dir():
282
- return current
295
+ return current, "legacy_claude_dir"
283
296
  current = current.parent
284
297
 
285
298
  # 3. Git root next — useful when this is the first time the user runs init.
@@ -292,7 +305,7 @@ def resolve_project_root(start_path: Path | None = None) -> Path:
292
305
  cwd=str(start),
293
306
  )
294
307
  if result.returncode == 0:
295
- return Path(result.stdout.strip()).resolve()
308
+ return Path(result.stdout.strip()).resolve(), "git_root"
296
309
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
297
310
  pass
298
311
 
@@ -300,12 +313,12 @@ def resolve_project_root(start_path: Path | None = None) -> Path:
300
313
  current = start
301
314
  while current != current.parent:
302
315
  if (current / ".claude").is_dir():
303
- return current
316
+ return current, "claude_dir"
304
317
  if (current / "pyproject.toml").is_file():
305
- return current
318
+ return current, "pyproject"
306
319
  current = current.parent
307
320
 
308
- return start
321
+ return start, "cwd_fallback"
309
322
 
310
323
 
311
324
  # Backwards-compatible alias for any external callers.
@@ -26,6 +26,7 @@ from agent_brain_cli.config import (
26
26
  get_server_url,
27
27
  load_config,
28
28
  resolve_project_root,
29
+ resolve_project_root_with_strategy,
29
30
  )
30
31
 
31
32
  #: Severity returned by every diagnostic check.
@@ -69,6 +70,49 @@ class DoctorReport:
69
70
  return data
70
71
 
71
72
 
73
+ _RESOLVE_STRATEGY_LABEL: dict[str, str] = {
74
+ "agent_brain_dir": f"found {STATE_DIR_NAME}/ in this dir or an ancestor",
75
+ "legacy_claude_dir": f"found legacy {LEGACY_STATE_DIR_NAME}/",
76
+ "git_root": "git repository root (no state dir present yet)",
77
+ "claude_dir": ".claude/ marker in this dir or an ancestor",
78
+ "pyproject": "pyproject.toml marker in this dir or an ancestor",
79
+ "cwd_fallback": "no markers found — falling back to cwd",
80
+ }
81
+
82
+
83
+ def _check_version() -> CheckResult:
84
+ """Confirm the installed agent-brain-cli is importable and report version.
85
+
86
+ Issue #146 check #2 — surfaces broken installs (missing entry-point,
87
+ namespace shadowing, half-rolled-back upgrades) at the top of the doctor
88
+ report instead of leaving the user to discover them later.
89
+ """
90
+ try:
91
+ from importlib.metadata import PackageNotFoundError, version
92
+
93
+ ver = version("agent-brain-cli")
94
+ except PackageNotFoundError as exc:
95
+ return CheckResult(
96
+ "cli_version",
97
+ SEVERITY_FAIL,
98
+ "agent-brain-cli is not installed in this Python environment.",
99
+ fix="pip install agent-brain-cli (or uv tool install agent-brain-cli)",
100
+ details={"error": str(exc)},
101
+ )
102
+ except Exception as exc: # noqa: BLE001
103
+ return CheckResult(
104
+ "cli_version",
105
+ SEVERITY_FAIL,
106
+ f"Could not determine agent-brain-cli version: {exc}",
107
+ )
108
+ return CheckResult(
109
+ "cli_version",
110
+ SEVERITY_OK,
111
+ f"agent-brain-cli {ver}",
112
+ details={"version": ver},
113
+ )
114
+
115
+
72
116
  def _check_python() -> CheckResult:
73
117
  major, minor = sys.version_info[:2]
74
118
  version = f"{major}.{minor}.{sys.version_info.micro}"
@@ -88,23 +132,35 @@ def _check_python() -> CheckResult:
88
132
  )
89
133
 
90
134
 
91
- def _check_project_init(project_root: Path, state_dir: Path) -> CheckResult:
135
+ def _check_project_init(
136
+ project_root: Path, state_dir: Path, resolved_via: str
137
+ ) -> CheckResult:
138
+ """Validate the resolved project root and explain *why* it was picked.
139
+
140
+ Issue #146 check #3 — operators on monorepos / nested projects can be
141
+ surprised by which directory wins; the strategy label tells them.
142
+ """
143
+ strategy_msg = _RESOLVE_STRATEGY_LABEL.get(resolved_via, resolved_via)
92
144
  config_path = state_dir / "config.json"
93
145
  if config_path.exists():
94
146
  return CheckResult(
95
147
  "project_initialized",
96
148
  SEVERITY_OK,
97
- f"Project initialized at {state_dir}",
98
- details={"state_dir": str(state_dir)},
149
+ f"Project initialized at {state_dir} ({strategy_msg})",
150
+ details={
151
+ "state_dir": str(state_dir),
152
+ "resolved_via": resolved_via,
153
+ },
99
154
  )
100
155
  return CheckResult(
101
156
  "project_initialized",
102
157
  SEVERITY_FAIL,
103
- f"No {STATE_DIR_NAME}/config.json under {project_root}",
158
+ (f"No {STATE_DIR_NAME}/config.json under {project_root} " f"({strategy_msg})"),
104
159
  fix="Run `agent-brain init` in your project directory.",
105
160
  details={
106
161
  "project_root": str(project_root),
107
162
  "expected_path": str(config_path),
163
+ "resolved_via": resolved_via,
108
164
  },
109
165
  )
110
166
 
@@ -223,11 +279,21 @@ def _check_server(server_url: str, runtime_file: Path | None) -> CheckResult:
223
279
  req = Request(server_url.rstrip("/") + "/health")
224
280
  with urlopen(req, timeout=3) as resp: # noqa: S310 — local URL
225
281
  body = resp.read().decode("utf-8", errors="replace")
282
+ # Issue #146 check #7 — also pull /health/status for the richer
283
+ # indexing summary. Tolerate older servers that 404 here.
284
+ indexing_summary, indexing_payload = _fetch_indexing_summary(server_url)
285
+ message = f"Server responded at {server_url}"
286
+ if indexing_summary:
287
+ message = f"{message} — {indexing_summary}"
226
288
  return CheckResult(
227
289
  "server_reachable",
228
290
  SEVERITY_OK,
229
- f"Server responded at {server_url}",
230
- details={"server_url": server_url, "response_preview": body[:120]},
291
+ message,
292
+ details={
293
+ "server_url": server_url,
294
+ "response_preview": body[:120],
295
+ "indexing": indexing_payload,
296
+ },
231
297
  )
232
298
  except URLError as exc:
233
299
  return CheckResult(
@@ -247,6 +313,30 @@ def _check_server(server_url: str, runtime_file: Path | None) -> CheckResult:
247
313
  )
248
314
 
249
315
 
316
+ def _fetch_indexing_summary(
317
+ server_url: str,
318
+ ) -> tuple[str | None, dict[str, Any] | None]:
319
+ """Best-effort fetch of /health/status, returning (one-line summary, raw)."""
320
+ try:
321
+ req = Request(server_url.rstrip("/") + "/health/status")
322
+ with urlopen(req, timeout=3) as resp: # noqa: S310 — local URL
323
+ payload = json.loads(resp.read().decode("utf-8", errors="replace"))
324
+ except Exception: # noqa: BLE001 — old server or transient error is fine
325
+ return None, None
326
+ if not isinstance(payload, dict):
327
+ return None, None
328
+ state = payload.get("state") or payload.get("indexing_state") or "unknown"
329
+ chunk_count = (
330
+ payload.get("chunk_count")
331
+ or payload.get("total_chunks")
332
+ or payload.get("document_count")
333
+ )
334
+ parts = [f"indexing={state}"]
335
+ if isinstance(chunk_count, int):
336
+ parts.append(f"chunks={chunk_count}")
337
+ return ", ".join(parts), payload
338
+
339
+
250
340
  def _check_optional_dep(provider: str, module_name: str, extra: str) -> CheckResult:
251
341
  """Report on an optional Python package that a chosen provider needs."""
252
342
  if shutil.which("python3"):
@@ -314,7 +404,7 @@ def _check_gitignore(project_root: Path) -> CheckResult:
314
404
 
315
405
  def run_doctor(server_url_override: str | None = None) -> DoctorReport:
316
406
  """Run every check and return a structured report."""
317
- project_root = resolve_project_root()
407
+ project_root, resolved_via = resolve_project_root_with_strategy()
318
408
  state_dir = project_root / STATE_DIR_NAME
319
409
  runtime_file: Path | None
320
410
  if state_dir.exists():
@@ -327,7 +417,8 @@ def run_doctor(server_url_override: str | None = None) -> DoctorReport:
327
417
 
328
418
  checks: list[CheckResult] = []
329
419
  checks.append(_check_python())
330
- checks.append(_check_project_init(project_root, state_dir))
420
+ checks.append(_check_version())
421
+ checks.append(_check_project_init(project_root, state_dir, resolved_via))
331
422
  checks.append(_check_provider_config(state_dir))
332
423
  checks.extend(_check_api_keys())
333
424
 
@@ -338,6 +429,13 @@ def run_doctor(server_url_override: str | None = None) -> DoctorReport:
338
429
  cfg = None
339
430
  if cfg and cfg.embedding.provider.lower() == "cohere":
340
431
  checks.append(_check_optional_dep("cohere", "cohere", "cohere"))
432
+
433
+ # Issue #146 check #8 — surface graphrag's langextract dependency.
434
+ if cfg and getattr(getattr(cfg, "graphrag", None), "enabled", False):
435
+ checks.append(
436
+ _check_optional_dep("graphrag (langextract)", "langextract", "graphrag")
437
+ )
438
+
341
439
  checks.append(_check_gitignore(project_root))
342
440
 
343
441
  checks.append(_check_server(server_url, runtime_file))
@@ -352,6 +450,49 @@ def run_doctor(server_url_override: str | None = None) -> DoctorReport:
352
450
  )
353
451
 
354
452
 
453
+ def apply_safe_fixes(report: DoctorReport) -> list[str]:
454
+ """Apply the subset of fixes that are safe + idempotent + offline.
455
+
456
+ Returns the list of human-readable actions taken (empty if nothing to fix).
457
+ Used by ``agent-brain doctor --fix``. Anything that calls the network,
458
+ modifies user code, or requires an API key is *not* covered here — the
459
+ user must still address those manually.
460
+ """
461
+ actions: list[str] = []
462
+ project_root = Path(report.project_root)
463
+ state_dir = Path(report.state_dir)
464
+ for check in report.checks:
465
+ if check.name == "gitignore_state_dir" and check.status != SEVERITY_OK:
466
+ gi = project_root / ".gitignore"
467
+ line = f"{STATE_DIR_NAME}/\n"
468
+ if gi.exists():
469
+ content = gi.read_text()
470
+ if not content.endswith("\n"):
471
+ content += "\n"
472
+ gi.write_text(content + line)
473
+ else:
474
+ gi.write_text(line)
475
+ actions.append(f"Added {STATE_DIR_NAME}/ to {gi}.")
476
+ elif check.name == "project_initialized" and check.status == SEVERITY_FAIL:
477
+ # Create the state dir + a minimal config.json shell so a follow-up
478
+ # `agent-brain init` (or any command) has something to read.
479
+ state_dir.mkdir(parents=True, exist_ok=True)
480
+ cfg_json = state_dir / "config.json"
481
+ if not cfg_json.exists():
482
+ cfg_json.write_text(
483
+ json.dumps(
484
+ {
485
+ "project_root": str(project_root),
486
+ "created_by": "agent-brain doctor --fix",
487
+ },
488
+ indent=2,
489
+ )
490
+ + "\n"
491
+ )
492
+ actions.append(f"Created {cfg_json}.")
493
+ return actions
494
+
495
+
355
496
  def doctor_hint_message(project_root: Path | None = None) -> str:
356
497
  """Suggest the doctor command — and call out the most likely setup issue.
357
498
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agent-brain-cli"
3
- version = "10.0.3"
3
+ version = "10.0.5"
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 = "^10.0.3"
30
+ agent-brain-rag = "^10.0.5"
31
31
 
32
32
  [tool.poetry.group.dev.dependencies]
33
33
  pytest = "^8.3.0"