agent-brain-cli 10.4.0__tar.gz → 10.5.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 (50) hide show
  1. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/PKG-INFO +2 -1
  2. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/__init__.py +1 -1
  3. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/install_agent.py +158 -32
  4. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/__init__.py +0 -2
  5. agent_brain_cli-10.5.0/agent_brain_cli/runtime/mcp_registration.py +316 -0
  6. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/tool_maps.py +1 -16
  7. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/types.py +0 -1
  8. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/pyproject.toml +2 -1
  9. agent_brain_cli-10.4.0/agent_brain_cli/runtime/gemini_converter.py +0 -119
  10. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/README.md +0 -0
  11. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/__main__.py +0 -0
  12. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/cli.py +0 -0
  13. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/client/__init__.py +0 -0
  14. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/client/api_client.py +0 -0
  15. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/client/protocol.py +0 -0
  16. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/client/transport.py +0 -0
  17. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/__init__.py +0 -0
  18. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/cache.py +0 -0
  19. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/config.py +0 -0
  20. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/doctor.py +0 -0
  21. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/folders.py +0 -0
  22. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/graph.py +0 -0
  23. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/index.py +0 -0
  24. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/init.py +0 -0
  25. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/inject.py +0 -0
  26. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/jobs.py +0 -0
  27. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/list_cmd.py +0 -0
  28. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/mcp.py +0 -0
  29. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/prompt.py +0 -0
  30. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/query.py +0 -0
  31. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/reset.py +0 -0
  32. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/resources.py +0 -0
  33. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/start.py +0 -0
  34. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/status.py +0 -0
  35. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/stop.py +0 -0
  36. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/types.py +0 -0
  37. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/commands/uninstall.py +0 -0
  38. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/config.py +0 -0
  39. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/config_migrate.py +0 -0
  40. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/config_schema.py +0 -0
  41. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/diagnostics.py +0 -0
  42. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/mcp_runtime.py +0 -0
  43. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/migration.py +0 -0
  44. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/claude_converter.py +0 -0
  45. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/codex_converter.py +0 -0
  46. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/converter_base.py +0 -0
  47. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/opencode_converter.py +0 -0
  48. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/parser.py +0 -0
  49. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.0}/agent_brain_cli/runtime/skill_runtime_converter.py +0 -0
  50. {agent_brain_cli-10.4.0 → agent_brain_cli-10.5.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.4.0
3
+ Version: 10.5.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
@@ -23,6 +23,7 @@ Requires-Dist: psutil (>=6.0,<7.0)
23
23
  Requires-Dist: pydantic (>=2.10.0,<3.0.0)
24
24
  Requires-Dist: pyyaml (>=6.0.0,<7.0.0)
25
25
  Requires-Dist: rich (>=13.9.0,<14.0.0)
26
+ Requires-Dist: tomlkit (>=0.13.0,<0.14.0)
26
27
  Project-URL: Documentation, https://github.com/SpillwaveSolutions/agent-brain/wiki
27
28
  Project-URL: Repository, https://github.com/SpillwaveSolutions/agent-brain
28
29
  Description-Content-Type: text/markdown
@@ -1,3 +1,3 @@
1
1
  """Doc-Serve CLI - Command-line interface for managing Doc-Serve server."""
2
2
 
3
- __version__ = "10.4.0"
3
+ __version__ = "10.5.0"
@@ -1,6 +1,8 @@
1
1
  """Install-agent command for installing runtime-specific plugin files."""
2
2
 
3
3
  import json
4
+ import os
5
+ from collections.abc import Callable
4
6
  from pathlib import Path
5
7
  from typing import Any
6
8
 
@@ -10,7 +12,12 @@ from rich.panel import Panel
10
12
 
11
13
  from agent_brain_cli.runtime.claude_converter import ClaudeConverter
12
14
  from agent_brain_cli.runtime.codex_converter import CodexConverter
13
- from agent_brain_cli.runtime.gemini_converter import GeminiConverter
15
+ from agent_brain_cli.runtime.mcp_registration import (
16
+ McpRegistrationResult,
17
+ register_claude_mcp,
18
+ register_codex_mcp,
19
+ register_opencode_mcp,
20
+ )
14
21
  from agent_brain_cli.runtime.opencode_converter import OpenCodeConverter
15
22
  from agent_brain_cli.runtime.parser import parse_plugin_dir
16
23
  from agent_brain_cli.runtime.skill_runtime_converter import SkillRuntimeConverter
@@ -28,10 +35,6 @@ INSTALL_DIRS: dict[str, dict[str, str]] = {
28
35
  "project": ".opencode/plugins/agent-brain",
29
36
  "global": "~/.config/opencode/plugins/agent-brain",
30
37
  },
31
- "gemini": {
32
- "project": ".gemini/plugins/agent-brain",
33
- "global": "~/.config/gemini/plugins/agent-brain",
34
- },
35
38
  "codex": {
36
39
  "project": ".codex/skills/agent-brain",
37
40
  "global": "~/.codex/skills/agent-brain",
@@ -42,17 +45,12 @@ INSTALL_DIRS: dict[str, dict[str, str]] = {
42
45
  DIR_REQUIRED_RUNTIMES = {"skill-runtime"}
43
46
 
44
47
  ConverterType = type[
45
- ClaudeConverter
46
- | OpenCodeConverter
47
- | GeminiConverter
48
- | SkillRuntimeConverter
49
- | CodexConverter
48
+ ClaudeConverter | OpenCodeConverter | SkillRuntimeConverter | CodexConverter
50
49
  ]
51
50
 
52
51
  CONVERTERS: dict[str, ConverterType] = {
53
52
  "claude": ClaudeConverter,
54
53
  "opencode": OpenCodeConverter,
55
- "gemini": GeminiConverter,
56
54
  "skill-runtime": SkillRuntimeConverter,
57
55
  "codex": CodexConverter,
58
56
  }
@@ -94,7 +92,89 @@ def _resolve_target_dir(
94
92
  return project_root / dir_template
95
93
 
96
94
 
97
- RUNTIME_CHOICES = ["claude", "opencode", "gemini", "skill-runtime", "codex"]
95
+ RUNTIME_CHOICES = ["claude", "opencode", "skill-runtime", "codex"]
96
+
97
+ # Runtimes for which we can auto-register the MCP server today, mapped to the
98
+ # writer that knows that runtime's config schema. All writers share the
99
+ # ``(config_path, state_dir, *, backend, auth, dry_run)`` signature.
100
+ MCP_REGISTRARS: dict[str, Callable[..., McpRegistrationResult]] = {
101
+ "claude": register_claude_mcp,
102
+ "opencode": register_opencode_mcp,
103
+ "codex": register_codex_mcp,
104
+ }
105
+
106
+
107
+ def _codex_config_path() -> Path:
108
+ """Return Codex's MCP config file, honoring ``$CODEX_HOME``."""
109
+ codex_home = os.environ.get("CODEX_HOME")
110
+ base = Path(codex_home) if codex_home else Path.home() / ".codex"
111
+ return base / "config.toml"
112
+
113
+
114
+ def _resolve_mcp_paths(
115
+ agent: str, scope: str, project_root: Path | None
116
+ ) -> tuple[Path, Path]:
117
+ """Return (config_path, state_dir) for MCP registration.
118
+
119
+ The config file follows each runtime's own discovery rules:
120
+
121
+ * **claude** — project ``.mcp.json`` at the root, global ``~/.claude.json``.
122
+ * **opencode** — project ``opencode.json`` at the root (highest-precedence
123
+ project config), global ``~/.config/opencode/opencode.json``.
124
+ * **codex** — always ``$CODEX_HOME/config.toml`` (default
125
+ ``~/.codex/config.toml``); Codex has no project-level MCP config, so the
126
+ file is shared and the project's ``.agent-brain`` is pinned via the entry.
127
+ """
128
+ root = project_root if project_root is not None else Path.cwd()
129
+ if agent == "opencode":
130
+ if scope == "global":
131
+ config = Path.home() / ".config" / "opencode" / "opencode.json"
132
+ return config, Path.home() / ".agent-brain"
133
+ return root / "opencode.json", root / ".agent-brain"
134
+ if agent == "codex":
135
+ state_dir = (
136
+ Path.home() / ".agent-brain" if scope == "global" else root / ".agent-brain"
137
+ )
138
+ return _codex_config_path(), state_dir
139
+ # claude (and any future mcpServers-style runtime)
140
+ if scope == "global":
141
+ return Path.home() / ".claude.json", Path.home() / ".agent-brain"
142
+ return root / ".mcp.json", root / ".agent-brain"
143
+
144
+
145
+ def _register_mcp(
146
+ agent: str,
147
+ scope: str,
148
+ project_root: Path | None,
149
+ mcp_auth: str,
150
+ mcp_backend: str,
151
+ dry_run: bool,
152
+ ) -> dict[str, Any]:
153
+ """Register the MCP server for supported runtimes; report what happened."""
154
+ registrar = MCP_REGISTRARS.get(agent)
155
+ if registrar is None:
156
+ return {
157
+ "skipped": True,
158
+ "reason": (
159
+ f"MCP auto-registration is currently supported only for "
160
+ f"{', '.join(sorted(MCP_REGISTRARS))}; configure "
161
+ f"{agent} manually (see the configuring-agent-brain skill)."
162
+ ),
163
+ }
164
+ config_path, state_dir = _resolve_mcp_paths(agent, scope, project_root)
165
+ result = registrar(
166
+ config_path,
167
+ state_dir,
168
+ backend=mcp_backend,
169
+ auth=mcp_auth,
170
+ dry_run=dry_run,
171
+ )
172
+ return {
173
+ "skipped": False,
174
+ "action": result.action,
175
+ "path": str(result.path),
176
+ "server_name": result.server_name,
177
+ }
98
178
 
99
179
 
100
180
  @click.command("install-agent")
@@ -146,6 +226,23 @@ RUNTIME_CHOICES = ["claude", "opencode", "gemini", "skill-runtime", "codex"]
146
226
  type=click.Path(exists=True, file_okay=False, resolve_path=True),
147
227
  help="Project path for --project scope (default: cwd)",
148
228
  )
229
+ @click.option(
230
+ "--with-mcp",
231
+ is_flag=True,
232
+ help="Also register the agent-brain MCP server (Claude Code, OpenCode, Codex)",
233
+ )
234
+ @click.option(
235
+ "--mcp-auth",
236
+ type=click.Choice(["none", "oauth"]),
237
+ default="none",
238
+ help="MCP client auth mode written into the registration (default: none)",
239
+ )
240
+ @click.option(
241
+ "--mcp-backend",
242
+ type=click.Choice(["auto", "uds", "http"]),
243
+ default="auto",
244
+ help="How the MCP server reaches agent-brain-serve (default: auto)",
245
+ )
149
246
  def install_agent_command(
150
247
  agent: str,
151
248
  scope: str,
@@ -154,6 +251,9 @@ def install_agent_command(
154
251
  dry_run: bool,
155
252
  json_output: bool,
156
253
  path: str | None,
254
+ with_mcp: bool,
255
+ mcp_auth: str,
256
+ mcp_backend: str,
157
257
  ) -> None:
158
258
  """Install Agent Brain plugin for a specific runtime.
159
259
 
@@ -164,7 +264,7 @@ def install_agent_command(
164
264
  Examples:
165
265
  agent-brain install-agent --agent claude --project
166
266
  agent-brain install-agent --agent opencode --global
167
- agent-brain install-agent --agent gemini --dry-run
267
+ agent-brain install-agent --agent claude --dry-run
168
268
  agent-brain install-agent --agent skill-runtime --dir ./my-skills
169
269
  agent-brain install-agent --agent codex
170
270
  """
@@ -220,6 +320,12 @@ def install_agent_command(
220
320
  converter = converter_cls()
221
321
  scope_enum = Scope.GLOBAL if scope == "global" else Scope.PROJECT
222
322
 
323
+ mcp_summary: dict[str, Any] | None = None
324
+ if with_mcp:
325
+ mcp_summary = _register_mcp(
326
+ agent, scope, project_root, mcp_auth, mcp_backend, dry_run
327
+ )
328
+
223
329
  if dry_run:
224
330
  _handle_dry_run(
225
331
  converter,
@@ -229,6 +335,7 @@ def install_agent_command(
229
335
  agent,
230
336
  scope,
231
337
  json_output,
338
+ mcp_summary,
232
339
  )
233
340
  return
234
341
 
@@ -250,6 +357,8 @@ def install_agent_command(
250
357
  "files_created": len(files),
251
358
  "source_dir": str(source),
252
359
  }
360
+ if mcp_summary is not None:
361
+ result["mcp_registration"] = mcp_summary
253
362
  click.echo(json.dumps(result, indent=2))
254
363
  else:
255
364
  console.print(
@@ -263,6 +372,7 @@ def install_agent_command(
263
372
  border_style="green",
264
373
  )
265
374
  )
375
+ _print_mcp_summary(mcp_summary)
266
376
 
267
377
  except SystemExit:
268
378
  raise
@@ -276,11 +386,7 @@ def install_agent_command(
276
386
 
277
387
  def _handle_dry_run(
278
388
  converter: (
279
- ClaudeConverter
280
- | OpenCodeConverter
281
- | GeminiConverter
282
- | SkillRuntimeConverter
283
- | CodexConverter
389
+ ClaudeConverter | OpenCodeConverter | SkillRuntimeConverter | CodexConverter
284
390
  ),
285
391
  bundle: Any,
286
392
  target: Path,
@@ -288,18 +394,26 @@ def _handle_dry_run(
288
394
  agent: str,
289
395
  scope: str,
290
396
  json_output: bool,
397
+ mcp_summary: dict[str, Any] | None = None,
291
398
  ) -> None:
292
399
  """Handle dry-run mode: simulate install in temp dir."""
293
400
  import tempfile
294
401
 
295
402
  with tempfile.TemporaryDirectory() as tmp:
296
- tmp_target = Path(tmp)
297
403
  # For Codex, pass tmp as project_root so AGENTS.md lands in tmpdir
298
404
  if isinstance(converter, CodexConverter):
405
+ tmp_target = Path(tmp)
299
406
  files = converter.install(
300
407
  bundle, tmp_target, scope_enum, project_root=Path(tmp)
301
408
  )
302
409
  else:
410
+ # Mirror the real target's full structure under tmp so converters
411
+ # that write *outside* target_dir stay inside the sandbox — e.g.
412
+ # OpenCode writes opencode.json at target_dir.parent.parent, which
413
+ # escapes to an unwritable ancestor (CI: "/") if the temp target is
414
+ # shallow. Rooting at tmp + the target's relative path keeps every
415
+ # parent the converter computes inside the throwaway dir.
416
+ tmp_target = Path(tmp) / target.relative_to(target.anchor)
303
417
  files = converter.install(bundle, tmp_target, scope_enum)
304
418
  # Remap paths to real target
305
419
  planned: list[Path] = []
@@ -311,19 +425,17 @@ def _handle_dry_run(
311
425
  planned.append(f)
312
426
 
313
427
  if json_output:
314
- click.echo(
315
- json.dumps(
316
- {
317
- "dry_run": True,
318
- "agent": agent,
319
- "scope": scope,
320
- "target_dir": str(target),
321
- "files": [str(f) for f in planned],
322
- "file_count": len(planned),
323
- },
324
- indent=2,
325
- )
326
- )
428
+ payload: dict[str, Any] = {
429
+ "dry_run": True,
430
+ "agent": agent,
431
+ "scope": scope,
432
+ "target_dir": str(target),
433
+ "files": [str(f) for f in planned],
434
+ "file_count": len(planned),
435
+ }
436
+ if mcp_summary is not None:
437
+ payload["mcp_registration"] = mcp_summary
438
+ click.echo(json.dumps(payload, indent=2))
327
439
  else:
328
440
  console.print(
329
441
  Panel(
@@ -338,3 +450,17 @@ def _handle_dry_run(
338
450
  )
339
451
  for f in planned:
340
452
  console.print(f" [dim]{f}[/]")
453
+ _print_mcp_summary(mcp_summary)
454
+
455
+
456
+ def _print_mcp_summary(mcp_summary: dict[str, Any] | None) -> None:
457
+ """Render the MCP registration outcome for human-readable output."""
458
+ if mcp_summary is None:
459
+ return
460
+ if mcp_summary.get("skipped"):
461
+ console.print(f"[yellow]MCP:[/] {mcp_summary['reason']}")
462
+ return
463
+ console.print(
464
+ f"[green]MCP server registered[/] ([bold]{mcp_summary['action']}[/]) "
465
+ f"→ {mcp_summary['path']}"
466
+ )
@@ -12,7 +12,6 @@ from agent_brain_cli.runtime.parser import (
12
12
  )
13
13
  from agent_brain_cli.runtime.tool_maps import (
14
14
  CLAUDE_TOOLS,
15
- GEMINI_TOOLS,
16
15
  OPENCODE_TOOLS,
17
16
  map_tool_name,
18
17
  map_tools,
@@ -33,7 +32,6 @@ from agent_brain_cli.runtime.types import (
33
32
 
34
33
  __all__ = [
35
34
  "CLAUDE_TOOLS",
36
- "GEMINI_TOOLS",
37
35
  "OPENCODE_TOOLS",
38
36
  "PluginAgent",
39
37
  "PluginBundle",
@@ -0,0 +1,316 @@
1
+ """Register the Agent Brain MCP server into a runtime's config file.
2
+
3
+ Each runtime discovers MCP servers from its own JSON config:
4
+
5
+ * **Claude Code** — an ``mcpServers`` object in a project-level ``.mcp.json`` (or
6
+ the user-level ``~/.claude.json`` for global scope). Each entry is
7
+ ``{command, args, env}``.
8
+ * **OpenCode** — an ``mcp`` object in the project-root ``opencode.json`` (or
9
+ ``~/.config/opencode/opencode.json`` for global scope). Each entry is
10
+ ``{type: "local", command: [...], enabled: true, environment: {...}}`` — the
11
+ executable and its args are fused into one ``command`` array.
12
+ * **Codex** — a ``[mcp_servers.<name>]`` TOML table in
13
+ ``$CODEX_HOME/config.toml`` (default ``~/.codex/config.toml``). The entry
14
+ shape mirrors Claude's (``command`` string, ``args`` array, ``env`` table) but
15
+ the file is TOML, so it is merged with tomlkit to preserve the user's other
16
+ servers, top-level keys, and comments.
17
+
18
+ These writers deliberately do NOT shell out to a runtime CLI (``claude mcp add``
19
+ etc.): the file-based path is dependency-free, deterministic, dry-run friendly,
20
+ and matches the file-writing pattern the rest of ``install-agent`` already uses.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import tomlkit
31
+ from tomlkit.exceptions import TOMLKitError
32
+
33
+ MCP_SERVER_NAME = "agent-brain"
34
+ MCP_COMMAND = "agent-brain-mcp"
35
+ OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json"
36
+
37
+
38
+ def _build_env(state_dir: Path, auth: str) -> dict[str, str]:
39
+ """Build the environment block shared by every runtime's entry.
40
+
41
+ The state dir is resolved to an absolute path — MCP clients launch the
42
+ server from an unknown cwd, so a relative path would break discovery.
43
+ ``oauth`` opts the client into the OAuth dance via ``AGENT_BRAIN_MCP_AUTH``.
44
+ """
45
+ env: dict[str, str] = {
46
+ "AGENT_BRAIN_STATE_DIR": str(state_dir.expanduser().resolve())
47
+ }
48
+ if auth == "oauth":
49
+ env["AGENT_BRAIN_MCP_AUTH"] = "oauth"
50
+ return env
51
+
52
+
53
+ def build_mcp_server_entry(
54
+ state_dir: Path,
55
+ backend: str = "auto",
56
+ auth: str = "none",
57
+ ) -> dict[str, Any]:
58
+ """Build the Claude Code ``mcpServers`` entry for the Agent Brain MCP server.
59
+
60
+ Args:
61
+ state_dir: The ``.agent-brain`` state directory the MCP server should use.
62
+ backend: How the MCP server reaches ``agent-brain-serve``
63
+ (``auto`` | ``uds`` | ``http``).
64
+ auth: ``none`` (default) or ``oauth``.
65
+
66
+ Returns:
67
+ A dict suitable for ``mcpServers[<name>]``.
68
+ """
69
+ return {
70
+ "command": MCP_COMMAND,
71
+ "args": ["--backend", backend],
72
+ "env": _build_env(state_dir, auth),
73
+ }
74
+
75
+
76
+ def build_opencode_mcp_entry(
77
+ state_dir: Path,
78
+ backend: str = "auto",
79
+ auth: str = "none",
80
+ ) -> dict[str, Any]:
81
+ """Build the OpenCode ``mcp`` entry for the Agent Brain MCP server.
82
+
83
+ OpenCode fuses the executable and its args into one ``command`` array, uses
84
+ ``environment`` (not ``env``), and marks local servers with
85
+ ``type: "local"`` and ``enabled: true``.
86
+
87
+ Args:
88
+ state_dir: The ``.agent-brain`` state directory the MCP server should use.
89
+ backend: How the MCP server reaches ``agent-brain-serve``
90
+ (``auto`` | ``uds`` | ``http``).
91
+ auth: ``none`` (default) or ``oauth``.
92
+
93
+ Returns:
94
+ A dict suitable for ``mcp[<name>]`` in ``opencode.json``.
95
+ """
96
+ return {
97
+ "type": "local",
98
+ "command": [MCP_COMMAND, "--backend", backend],
99
+ "enabled": True,
100
+ "environment": _build_env(state_dir, auth),
101
+ }
102
+
103
+
104
+ @dataclass
105
+ class McpRegistrationResult:
106
+ """Outcome of an MCP registration attempt."""
107
+
108
+ path: Path
109
+ action: str # "created" | "updated" | "unchanged"
110
+ server_name: str
111
+ entry: dict[str, Any]
112
+
113
+
114
+ def _merge_json_mcp(
115
+ config_path: Path,
116
+ *,
117
+ servers_key: str,
118
+ entry: dict[str, Any],
119
+ top_level_defaults: dict[str, Any] | None = None,
120
+ dry_run: bool = False,
121
+ ) -> McpRegistrationResult:
122
+ """Merge an MCP entry into a JSON config under ``servers_key``.
123
+
124
+ Shared skeleton for every JSON-config runtime: read (fail closed on corrupt
125
+ JSON), compare the existing ``agent-brain`` entry, classify the action, and
126
+ write only when something changed. Other servers and top-level keys are
127
+ preserved.
128
+
129
+ Args:
130
+ config_path: Path to the runtime's JSON config file.
131
+ servers_key: Top-level key holding the server map
132
+ (``mcpServers`` for Claude, ``mcp`` for OpenCode).
133
+ entry: The fully-built entry to store under ``[servers_key][name]``.
134
+ top_level_defaults: Keys applied via ``setdefault`` when writing
135
+ (e.g. OpenCode's ``$schema``); only touched on create/update.
136
+ dry_run: When True, compute the action but write nothing.
137
+
138
+ Returns:
139
+ An :class:`McpRegistrationResult` describing what (would have) changed.
140
+
141
+ Raises:
142
+ ValueError: If an existing config file is not valid JSON.
143
+ """
144
+ data: dict[str, Any]
145
+ file_existed = config_path.exists()
146
+ if file_existed:
147
+ try:
148
+ data = json.loads(config_path.read_text())
149
+ except json.JSONDecodeError as exc:
150
+ raise ValueError(
151
+ f"Existing {config_path.name} is not valid JSON: {exc}. "
152
+ "Fix or remove it, then re-run."
153
+ ) from exc
154
+ else:
155
+ data = {}
156
+
157
+ servers = data.setdefault(servers_key, {})
158
+ existing = servers.get(MCP_SERVER_NAME)
159
+
160
+ if existing == entry:
161
+ action = "unchanged"
162
+ elif not file_existed:
163
+ action = "created"
164
+ else:
165
+ action = "updated"
166
+
167
+ if action != "unchanged" and not dry_run:
168
+ for key, value in (top_level_defaults or {}).items():
169
+ data.setdefault(key, value)
170
+ servers[MCP_SERVER_NAME] = entry
171
+ config_path.parent.mkdir(parents=True, exist_ok=True)
172
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
173
+
174
+ return McpRegistrationResult(
175
+ path=config_path,
176
+ action=action,
177
+ server_name=MCP_SERVER_NAME,
178
+ entry=entry,
179
+ )
180
+
181
+
182
+ def register_claude_mcp(
183
+ config_path: Path,
184
+ state_dir: Path,
185
+ *,
186
+ backend: str = "auto",
187
+ auth: str = "none",
188
+ dry_run: bool = False,
189
+ ) -> McpRegistrationResult:
190
+ """Merge the Agent Brain MCP entry into a Claude Code config file.
191
+
192
+ Args:
193
+ config_path: Path to ``.mcp.json`` (project) or ``~/.claude.json`` (global).
194
+ state_dir: The ``.agent-brain`` state directory for the server.
195
+ backend: MCP backend selector passed to the server.
196
+ auth: ``none`` or ``oauth``.
197
+ dry_run: When True, compute the action but write nothing.
198
+
199
+ Returns:
200
+ An :class:`McpRegistrationResult` describing what (would have) changed.
201
+
202
+ Raises:
203
+ ValueError: If an existing config file is not valid JSON.
204
+ """
205
+ return _merge_json_mcp(
206
+ config_path,
207
+ servers_key="mcpServers",
208
+ entry=build_mcp_server_entry(state_dir, backend=backend, auth=auth),
209
+ dry_run=dry_run,
210
+ )
211
+
212
+
213
+ def register_opencode_mcp(
214
+ config_path: Path,
215
+ state_dir: Path,
216
+ *,
217
+ backend: str = "auto",
218
+ auth: str = "none",
219
+ dry_run: bool = False,
220
+ ) -> McpRegistrationResult:
221
+ """Merge the Agent Brain MCP entry into an OpenCode config file.
222
+
223
+ Args:
224
+ config_path: Path to the project-root ``opencode.json`` (project) or
225
+ ``~/.config/opencode/opencode.json`` (global).
226
+ state_dir: The ``.agent-brain`` state directory for the server.
227
+ backend: MCP backend selector passed to the server.
228
+ auth: ``none`` or ``oauth``.
229
+ dry_run: When True, compute the action but write nothing.
230
+
231
+ Returns:
232
+ An :class:`McpRegistrationResult` describing what (would have) changed.
233
+
234
+ Raises:
235
+ ValueError: If an existing config file is not valid JSON.
236
+ """
237
+ return _merge_json_mcp(
238
+ config_path,
239
+ servers_key="mcp",
240
+ entry=build_opencode_mcp_entry(state_dir, backend=backend, auth=auth),
241
+ top_level_defaults={"$schema": OPENCODE_SCHEMA_URL},
242
+ dry_run=dry_run,
243
+ )
244
+
245
+
246
+ def register_codex_mcp(
247
+ config_path: Path,
248
+ state_dir: Path,
249
+ *,
250
+ backend: str = "auto",
251
+ auth: str = "none",
252
+ dry_run: bool = False,
253
+ ) -> McpRegistrationResult:
254
+ """Merge the Agent Brain MCP entry into a Codex ``config.toml`` file.
255
+
256
+ Codex stores MCP servers as ``[mcp_servers.<name>]`` TOML tables. The entry
257
+ fields match Claude's (``command``/``args``/``env``), so the value is built
258
+ with :func:`build_mcp_server_entry`; only the on-disk format differs. tomlkit
259
+ parses and re-emits the document so the user's other servers, top-level keys,
260
+ and comments are preserved.
261
+
262
+ Args:
263
+ config_path: Path to ``$CODEX_HOME/config.toml`` (default
264
+ ``~/.codex/config.toml``). Codex has no project-level MCP config, so
265
+ both install scopes target this user-level file.
266
+ state_dir: The ``.agent-brain`` state directory for the server.
267
+ backend: MCP backend selector passed to the server.
268
+ auth: ``none`` or ``oauth``.
269
+ dry_run: When True, compute the action but write nothing.
270
+
271
+ Returns:
272
+ An :class:`McpRegistrationResult` describing what (would have) changed.
273
+
274
+ Raises:
275
+ ValueError: If an existing config file is not valid TOML.
276
+ """
277
+ entry = build_mcp_server_entry(state_dir, backend=backend, auth=auth)
278
+
279
+ file_existed = config_path.exists()
280
+ if file_existed:
281
+ try:
282
+ doc = tomlkit.parse(config_path.read_text())
283
+ except TOMLKitError as exc:
284
+ raise ValueError(
285
+ f"Existing {config_path.name} is not valid TOML: {exc}. "
286
+ "Fix or remove it, then re-run."
287
+ ) from exc
288
+ else:
289
+ doc = tomlkit.document()
290
+
291
+ servers = doc.get("mcp_servers")
292
+ if servers is None:
293
+ servers = tomlkit.table()
294
+ doc["mcp_servers"] = servers
295
+
296
+ existing = servers.get(MCP_SERVER_NAME)
297
+ existing_plain = existing.unwrap() if existing is not None else None
298
+
299
+ if existing_plain == entry:
300
+ action = "unchanged"
301
+ elif not file_existed:
302
+ action = "created"
303
+ else:
304
+ action = "updated"
305
+
306
+ if action != "unchanged" and not dry_run:
307
+ servers[MCP_SERVER_NAME] = entry
308
+ config_path.parent.mkdir(parents=True, exist_ok=True)
309
+ config_path.write_text(tomlkit.dumps(doc))
310
+
311
+ return McpRegistrationResult(
312
+ path=config_path,
313
+ action=action,
314
+ server_name=MCP_SERVER_NAME,
315
+ entry=entry,
316
+ )
@@ -36,25 +36,10 @@ OPENCODE_TOOLS: dict[str, str] = {
36
36
  "TodoWrite": "todowrite",
37
37
  }
38
38
 
39
- # Gemini CLI — different tool name convention
40
- GEMINI_TOOLS: dict[str, str] = {
41
- "Bash": "run_shell_command",
42
- "Read": "read_file",
43
- "Write": "write_file",
44
- "Edit": "replace",
45
- "Glob": "glob",
46
- "Grep": "grep",
47
- "Agent": "agent",
48
- "WebFetch": "web_fetch",
49
- "WebSearch": "web_search",
50
- "NotebookEdit": "notebook_edit",
51
- }
52
-
53
39
  # Mapping from RuntimeType to tool map
54
40
  TOOL_MAPS: dict[str, dict[str, str]] = {
55
41
  "claude": CLAUDE_TOOLS,
56
42
  "opencode": OPENCODE_TOOLS,
57
- "gemini": GEMINI_TOOLS,
58
43
  }
59
44
 
60
45
 
@@ -67,7 +52,7 @@ def map_tool_name(tool: str, runtime: str) -> str:
67
52
 
68
53
  Args:
69
54
  tool: Canonical tool name (e.g., "Bash", "Write(.agent-brain/**)").
70
- runtime: Target runtime ("claude", "opencode", "gemini").
55
+ runtime: Target runtime ("claude", "opencode").
71
56
 
72
57
  Returns:
73
58
  Mapped tool name, or the lowercased base name if no mapping exists.
@@ -9,7 +9,6 @@ class RuntimeType(str, Enum):
9
9
 
10
10
  CLAUDE = "claude"
11
11
  OPENCODE = "opencode"
12
- GEMINI = "gemini"
13
12
  SKILL_RUNTIME = "skill-runtime"
14
13
  CODEX = "codex"
15
14
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agent-brain-cli"
3
- version = "10.4.0"
3
+ version = "10.5.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"
@@ -26,6 +26,7 @@ click = "^8.1.0"
26
26
  httpx = "^0.28.0"
27
27
  rich = "^13.9.0"
28
28
  pyyaml = "^6.0.0"
29
+ tomlkit = "^0.13.0"
29
30
  pydantic = "^2.10.0"
30
31
  agent-brain-rag = "^10.4.0"
31
32
  agent-brain-uds = "^10.3.0"
@@ -1,119 +0,0 @@
1
- """Gemini CLI runtime converter.
2
-
3
- Gemini uses different tool names and doesn't support some fields:
4
- - Tool names: Read→read_file, Write→write_file, Edit→replace, Bash→run_shell_command
5
- - No `color` field support
6
- - Skills use `tools` list with Gemini-mapped names
7
- """
8
-
9
- import logging
10
- from pathlib import Path
11
-
12
- import yaml
13
-
14
- from agent_brain_cli.runtime.tool_maps import map_tools
15
- from agent_brain_cli.runtime.types import (
16
- PluginAgent,
17
- PluginBundle,
18
- PluginCommand,
19
- PluginSkill,
20
- RuntimeType,
21
- Scope,
22
- )
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- LEGACY_PATH = ".claude/agent-brain"
27
- NEW_PATH = ".agent-brain"
28
-
29
-
30
- def _replace_paths(text: str) -> str:
31
- return text.replace(LEGACY_PATH, NEW_PATH)
32
-
33
-
34
- def _rebuild_file(frontmatter: dict, body: str) -> str: # type: ignore[type-arg]
35
- yaml_str = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
36
- return f"---\n{yaml_str}---\n{body}\n"
37
-
38
-
39
- class GeminiConverter:
40
- """Converter for Gemini CLI runtime."""
41
-
42
- @property
43
- def runtime_type(self) -> RuntimeType:
44
- return RuntimeType.GEMINI
45
-
46
- def convert_command(self, command: PluginCommand) -> str:
47
- fm: dict[str, object] = {
48
- "name": command.name,
49
- "description": command.description,
50
- "parameters": [
51
- {
52
- "name": p.name,
53
- "description": p.description,
54
- "required": p.required,
55
- **({"default": p.default} if p.default else {}),
56
- }
57
- for p in command.parameters
58
- ],
59
- "skills": command.skills,
60
- }
61
- return _rebuild_file(fm, _replace_paths(command.body))
62
-
63
- def convert_agent(self, agent: PluginAgent) -> str:
64
- fm: dict[str, object] = {
65
- "name": agent.name,
66
- "description": agent.description,
67
- "triggers": [
68
- {"pattern": t.pattern, "type": t.type} for t in agent.triggers
69
- ],
70
- "skills": agent.skills,
71
- }
72
- return _rebuild_file(fm, _replace_paths(agent.body))
73
-
74
- def convert_skill(self, skill: PluginSkill) -> str:
75
- """Convert skill with Gemini-mapped tool names, no color field."""
76
- mapped_tools = map_tools(skill.allowed_tools, "gemini")
77
- # Filter out unsupported metadata fields
78
- clean_metadata = {k: v for k, v in skill.metadata.items() if k != "color"}
79
- fm: dict[str, object] = {
80
- "name": skill.name,
81
- "description": skill.description,
82
- "license": skill.license,
83
- "allowed-tools": mapped_tools,
84
- "metadata": clean_metadata,
85
- }
86
- return _rebuild_file(fm, _replace_paths(skill.body))
87
-
88
- def install(
89
- self,
90
- bundle: PluginBundle,
91
- target_dir: Path,
92
- scope: Scope,
93
- ) -> list[Path]:
94
- """Install Gemini CLI plugin files."""
95
- created: list[Path] = []
96
-
97
- cmds_dir = target_dir / "commands"
98
- cmds_dir.mkdir(parents=True, exist_ok=True)
99
- for cmd in bundle.commands:
100
- out = cmds_dir / f"{cmd.name}.md"
101
- out.write_text(self.convert_command(cmd), encoding="utf-8")
102
- created.append(out)
103
-
104
- agents_dir = target_dir / "agents"
105
- agents_dir.mkdir(parents=True, exist_ok=True)
106
- for agent in bundle.agents:
107
- out = agents_dir / f"{agent.name}.md"
108
- out.write_text(self.convert_agent(agent), encoding="utf-8")
109
- created.append(out)
110
-
111
- skills_dir = target_dir / "skills"
112
- for skill in bundle.skills:
113
- skill_out = skills_dir / skill.name
114
- skill_out.mkdir(parents=True, exist_ok=True)
115
- skill_file = skill_out / "SKILL.md"
116
- skill_file.write_text(self.convert_skill(skill), encoding="utf-8")
117
- created.append(skill_file)
118
-
119
- return created