gemcode 0.3.30__tar.gz → 0.3.32__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 (122) hide show
  1. {gemcode-0.3.30/src/gemcode.egg-info → gemcode-0.3.32}/PKG-INFO +1 -1
  2. {gemcode-0.3.30 → gemcode-0.3.32}/pyproject.toml +1 -1
  3. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/agent.py +103 -6
  4. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/callbacks.py +22 -0
  5. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/cli.py +4 -14
  6. gemcode-0.3.32/src/gemcode/mcp_loader.py +147 -0
  7. gemcode-0.3.32/src/gemcode/openapi_loader.py +127 -0
  8. gemcode-0.3.32/src/gemcode/permissions.py +206 -0
  9. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/repl_commands.py +36 -9
  10. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/repl_slash.py +236 -4
  11. gemcode-0.3.32/src/gemcode/review_agent.py +142 -0
  12. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/session_runtime.py +21 -0
  13. gemcode-0.3.32/src/gemcode/session_store.py +195 -0
  14. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/__init__.py +3 -1
  15. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/bash.py +146 -0
  16. gemcode-0.3.32/src/gemcode/tools/notes.py +106 -0
  17. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tui/input_handler.py +5 -0
  18. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tui/scrollback.py +13 -0
  19. {gemcode-0.3.30 → gemcode-0.3.32/src/gemcode.egg-info}/PKG-INFO +1 -1
  20. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode.egg-info/SOURCES.txt +4 -0
  21. gemcode-0.3.30/src/gemcode/mcp_loader.py +0 -57
  22. gemcode-0.3.30/src/gemcode/permissions.py +0 -5
  23. {gemcode-0.3.30 → gemcode-0.3.32}/LICENSE +0 -0
  24. {gemcode-0.3.30 → gemcode-0.3.32}/MANIFEST.in +0 -0
  25. {gemcode-0.3.30 → gemcode-0.3.32}/README.md +0 -0
  26. {gemcode-0.3.30 → gemcode-0.3.32}/setup.cfg +0 -0
  27. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/__init__.py +0 -0
  28. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/__main__.py +0 -0
  29. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/audit.py +0 -0
  30. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/autocompact.py +0 -0
  31. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/capability_routing.py +0 -0
  32. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/compaction.py +0 -0
  33. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/computer_use/__init__.py +0 -0
  34. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/computer_use/browser_computer.py +0 -0
  35. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/config.py +0 -0
  36. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/context_budget.py +0 -0
  37. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/context_warning.py +0 -0
  38. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/credentials.py +0 -0
  39. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/hitl_session.py +0 -0
  40. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/hooks.py +0 -0
  41. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/interactions.py +0 -0
  42. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/invoke.py +0 -0
  43. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/kairos_daemon.py +0 -0
  44. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/limits.py +0 -0
  45. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/live_audio_engine.py +0 -0
  46. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/logging_config.py +0 -0
  47. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/memory/__init__.py +0 -0
  48. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/memory/embedding_memory_service.py +0 -0
  49. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/memory/file_memory_service.py +0 -0
  50. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/modality_tools.py +0 -0
  51. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/model_errors.py +0 -0
  52. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/model_routing.py +0 -0
  53. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/paths.py +0 -0
  54. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/plugins/__init__.py +0 -0
  55. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  56. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  57. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/pricing.py +0 -0
  58. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/prompt_suggestions.py +0 -0
  59. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/__init__.py +0 -0
  60. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/config.py +0 -0
  61. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/deps.py +0 -0
  62. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/engine.py +0 -0
  63. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/stop_hooks.py +0 -0
  64. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/token_budget.py +0 -0
  65. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/query/transitions.py +0 -0
  66. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/refine.py +0 -0
  67. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/slash_commands.py +0 -0
  68. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/thinking.py +0 -0
  69. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tool_prompt_manifest.py +0 -0
  70. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tool_registry.py +0 -0
  71. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/browser.py +0 -0
  72. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/edit.py +0 -0
  73. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/filesystem.py +0 -0
  74. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/search.py +0 -0
  75. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/shell.py +0 -0
  76. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/shell_gate.py +0 -0
  77. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/subtask.py +0 -0
  78. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/think.py +0 -0
  79. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/todo.py +0 -0
  80. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools/web.py +0 -0
  81. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tools_inspector.py +0 -0
  82. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/trust.py +0 -0
  83. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tui/spinner.py +0 -0
  84. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tui/welcome_banner.py +0 -0
  85. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/tui/welcome_rich.py +0 -0
  86. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/version.py +0 -0
  87. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/vertex.py +0 -0
  88. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/web/__init__.py +0 -0
  89. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/web/claude_sse_adapter.py +0 -0
  90. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/web/terminal_repl.py +0 -0
  91. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode/workspace_hints.py +0 -0
  92. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode.egg-info/dependency_links.txt +0 -0
  93. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode.egg-info/entry_points.txt +0 -0
  94. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode.egg-info/requires.txt +0 -0
  95. {gemcode-0.3.30 → gemcode-0.3.32}/src/gemcode.egg-info/top_level.txt +0 -0
  96. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_agent_instruction.py +0 -0
  97. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_autocompact.py +0 -0
  98. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_capability_routing.py +0 -0
  99. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_claude_web_adapter_sse.py +0 -0
  100. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_cli_init.py +0 -0
  101. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_computer_use_permissions.py +0 -0
  102. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_context_budget.py +0 -0
  103. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_context_warning.py +0 -0
  104. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_credentials.py +0 -0
  105. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_interactive_permission_ask.py +0 -0
  106. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_kairos_scheduler.py +0 -0
  107. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_modality_tools.py +0 -0
  108. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_model_error_retry.py +0 -0
  109. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_model_errors.py +0 -0
  110. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_model_routing.py +0 -0
  111. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_paths.py +0 -0
  112. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_permissions.py +0 -0
  113. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_prompt_suggestions.py +0 -0
  114. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_repl_commands.py +0 -0
  115. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_repl_slash.py +0 -0
  116. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_slash_commands.py +0 -0
  117. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_thinking_config.py +0 -0
  118. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_token_budget.py +0 -0
  119. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_tool_context_circulation.py +0 -0
  120. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_tools.py +0 -0
  121. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_tools_inspector.py +0 -0
  122. {gemcode-0.3.30 → gemcode-0.3.32}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.30
3
+ Version: 0.3.32
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.30"
7
+ version = "0.3.32"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -49,11 +49,77 @@ def _chain_before_model_callbacks(*callbacks):
49
49
 
50
50
 
51
51
  def _load_gemini_md(project_root: Path) -> str:
52
- for name in ("GEMINI.md", "gemini.md"):
53
- p = project_root / name
54
- if p.is_file():
55
- return p.read_text(encoding="utf-8", errors="replace")[:50_000]
56
- return ""
52
+ """
53
+ Load GEMINI.md / .gemcode/NOTES.md from a Claude Code–style hierarchy.
54
+
55
+ Priority (later entries override earlier ones, all are concatenated):
56
+ 1. ~/.gemcode/GEMINI.md — user-global instructions (all projects)
57
+ 2. Walk UP from project_root: each directory's GEMINI.md / .gemcode/GEMINI.md
58
+ (org-level files at higher dirs, project-level at project_root)
59
+ 3. project_root/GEMINI.md — the primary project instructions
60
+ 4. project_root/.gemcode/GEMINI.md — alternative location
61
+ 5. project_root/.gemcode/notes.md — agent auto-generated notes (read-only context)
62
+
63
+ Max total: 80,000 chars. Each file is capped at 30,000 chars.
64
+ HTML comments (<!-- ... -->) are stripped before injection (saves tokens).
65
+ """
66
+ import re
67
+
68
+ _NAMES = ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md")
69
+ _FILE_CAP = 30_000
70
+ _TOTAL_CAP = 80_000
71
+ _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
72
+
73
+ def _read(p: Path) -> str:
74
+ if not p.is_file():
75
+ return ""
76
+ try:
77
+ raw = p.read_text(encoding="utf-8", errors="replace")[:_FILE_CAP]
78
+ # Strip HTML comments (like Claude Code does — saves tokens)
79
+ return _COMMENT_RE.sub("", raw).strip()
80
+ except OSError:
81
+ return ""
82
+
83
+ seen: set[Path] = set()
84
+ sections: list[str] = []
85
+
86
+ def _add(p: Path, label: str | None = None) -> None:
87
+ resolved = p.resolve()
88
+ if resolved in seen:
89
+ return
90
+ seen.add(resolved)
91
+ text = _read(p)
92
+ if text:
93
+ sections.append(f"<!-- {label or str(p)} -->\n{text}" if label else text)
94
+
95
+ # 1. User-global: ~/.gemcode/GEMINI.md
96
+ user_global = Path.home() / ".gemcode" / "GEMINI.md"
97
+ _add(user_global, "user-global (~/.gemcode/GEMINI.md)")
98
+
99
+ # 2. Walk UP from project_root to filesystem root — loads org / monorepo-level instructions
100
+ walk = project_root.resolve()
101
+ ancestors = []
102
+ while walk != walk.parent:
103
+ walk = walk.parent
104
+ if walk == Path.home() or walk == Path("/"):
105
+ break
106
+ ancestors.append(walk)
107
+ # Walk outer→inner (org first, closer dirs later — later = higher priority)
108
+ for ancestor in reversed(ancestors):
109
+ for name in _NAMES:
110
+ _add(ancestor / name)
111
+
112
+ # 3+4. Project-root level instructions (primary location)
113
+ for name in ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md", ".gemcode/gemini.md"):
114
+ _add(project_root / name)
115
+
116
+ # 5. Agent-generated notes (informational context, not instructions)
117
+ notes = project_root / ".gemcode" / "notes.md"
118
+ if notes.is_file():
119
+ _add(notes, "agent notes (.gemcode/notes.md)")
120
+
121
+ combined = "\n\n---\n\n".join(s for s in sections if s.strip())
122
+ return combined[:_TOTAL_CAP]
57
123
 
58
124
 
59
125
  def _build_runtime_facts(cfg: GemCodeConfig) -> str:
@@ -383,6 +449,13 @@ You have native deep thinking capability — use it actively:
383
449
  - For **dev servers**: `bash("npm run dev", background=True, cwd_subdir="frontend")`
384
450
  - For **subfolders**: `bash("cargo build --release", cwd_subdir="backend")`
385
451
 
452
+ - **`bash_stream`** — streaming version of bash: yields stdout line-by-line in real-time. Use for long-running commands where the user wants to see live progress:
453
+ - `bash_stream("pytest -v tests/")` — see each test result as it runs
454
+ - `bash_stream("npm run build")` — watch build progress in real-time
455
+ - `bash_stream("pip install -r requirements.txt")` — see install progress
456
+ - `bash_stream("tail -f logs/app.log", timeout_seconds=60)` — live log watching
457
+ - Prefer `bash_stream` over `bash` whenever the command takes > 5 seconds and progress visibility matters
458
+
386
459
  - **`run_command`** — simple single-executable calls without shell features:
387
460
  - `run_command("npm", args=["install", "--legacy-peer-deps"])` — clean npm install
388
461
  - `run_command("python3", args=["-m", "pytest", "--version"])` — version check
@@ -527,7 +600,23 @@ For tasks where quality matters:
527
600
  - Prefer small, testable, accurate changes over broad rewrites.
528
601
 
529
602
  ## Workspace scope
530
- All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`."""
603
+ All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`.
604
+
605
+ ## Agent notes (.gemcode/notes.md)
606
+ You have two tools to persist project insights across sessions, like Claude Code's auto-memory:
607
+
608
+ - **`append_project_note(note)`** — write a note to `.gemcode/notes.md`. Use this proactively when you discover something worth remembering:
609
+ - Build/test/lint commands you discover ("Build: `npm run build` — requires Node 20")
610
+ - Key file locations ("Auth middleware: `src/middleware/auth.ts`")
611
+ - Known issues or patterns ("DB migrations: always run `prisma db push` after schema changes")
612
+ - User workflow preferences ("User prefers running tests before committing")
613
+ - Architecture decisions or tricky patterns
614
+
615
+ Call this **immediately** when you discover something useful — not just at the end of tasks.
616
+ Notes are loaded at session start so future sessions inherit this knowledge.
617
+
618
+ - **`read_project_notes()`** — read current notes before starting a new project task.
619
+ If notes exist and you haven't read them yet, read them first to avoid re-discovering known information."""
531
620
 
532
621
  # Inject capability-specific strategy sections only when those caps are on.
533
622
  if getattr(cfg, "enable_computer_use", False):
@@ -590,6 +679,14 @@ def build_root_agent(
590
679
  except Exception:
591
680
  pass
592
681
 
682
+ # Agent auto-notes: write project insights to .gemcode/notes.md (Claude Code MEMORY.md equivalent)
683
+ try:
684
+ from gemcode.tools.notes import build_notes_tools
685
+ notes_tools = build_notes_tools(cfg.project_root)
686
+ tools = [*tools, *notes_tools]
687
+ except Exception:
688
+ pass
689
+
593
690
  if extra_tools:
594
691
  tools = [*tools, *extra_tools]
595
692
 
@@ -157,6 +157,24 @@ def make_before_tool_callback(cfg: GemCodeConfig):
157
157
  except Exception:
158
158
  pass
159
159
 
160
+ # ── Permission rules (.gemcode/settings.json allow/deny) ──────────────
161
+ # Evaluated before the normal permission flow so explicit allow/deny rules
162
+ # override cfg.permission_mode and interactive prompts.
163
+ try:
164
+ from gemcode.permissions import check_rules
165
+ rule_result = check_rules(name, args or {}, cfg.project_root)
166
+ if rule_result == "deny":
167
+ return {
168
+ "error": f"Tool call blocked by .gemcode/settings.json deny rule for '{name}'.",
169
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
170
+ }
171
+ if rule_result == "allow":
172
+ # Explicit allow — skip the normal permission prompt entirely
173
+ # but still run post-tool hook (logging/audit).
174
+ pass # fall through to tool execution
175
+ except Exception:
176
+ rule_result = None
177
+
160
178
  streak = 0
161
179
  if tool_context is not None:
162
180
  try:
@@ -181,6 +199,10 @@ def make_before_tool_callback(cfg: GemCodeConfig):
181
199
  "error_kind": _ERROR_KIND_CIRCUIT_BREAKER,
182
200
  }
183
201
 
202
+ # If permission rules explicitly allowed this tool call, skip the gate entirely.
203
+ if rule_result == "allow" and (name in MUTATING_TOOLS or is_computer_tool):
204
+ return None # allow without prompting
205
+
184
206
  if name in MUTATING_TOOLS or is_computer_tool:
185
207
  if cfg.permission_mode == "strict":
186
208
  if is_computer_tool:
@@ -151,13 +151,8 @@ async def _run_prompt(
151
151
  _maybe_prompt_google_api_key()
152
152
  require_google_api_key()
153
153
  _initialize_gemcode_project(cfg)
154
- extra: list = []
155
- if use_mcp:
156
- from gemcode.mcp_loader import load_mcp_toolsets
157
-
158
- extra = load_mcp_toolsets(cfg)
159
-
160
- runner = create_runner(cfg, extra_tools=extra or None)
154
+ # MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
155
+ runner = create_runner(cfg, extra_tools=None)
161
156
  try:
162
157
  collected = await run_turn(
163
158
  runner,
@@ -184,13 +179,8 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
184
179
  require_google_api_key()
185
180
  _initialize_gemcode_project(cfg)
186
181
 
187
- extra: list = []
188
- if use_mcp:
189
- from gemcode.mcp_loader import load_mcp_toolsets
190
-
191
- extra = load_mcp_toolsets(cfg)
192
-
193
- runner = create_runner(cfg, extra_tools=extra or None)
182
+ # MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
183
+ runner = create_runner(cfg, extra_tools=None)
194
184
  try:
195
185
  # For CLI UX, show concise tool summaries (helps users see what ran).
196
186
  if os.environ.get("GEMCODE_EMIT_TOOL_USE_SUMMARIES") is None:
@@ -0,0 +1,147 @@
1
+ """
2
+ Optional MCP toolsets from `.gemcode/mcp.json`.
3
+
4
+ Now supports all three ADK connection types — like Claude Code's MCP integration:
5
+
6
+ Schema (example):
7
+ {
8
+ "servers": [
9
+ {
10
+ "name": "filesystem",
11
+ "stdio": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }
12
+ },
13
+ {
14
+ "name": "github",
15
+ "http": {
16
+ "url": "https://api.githubcopilot.com/mcp/",
17
+ "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
18
+ }
19
+ },
20
+ {
21
+ "name": "notion",
22
+ "sse": { "url": "https://mcp.notion.com/sse" }
23
+ }
24
+ ]
25
+ }
26
+
27
+ Connection types:
28
+ stdio — local subprocess (npx, python, etc.) — legacy, always worked
29
+ http — Streamable HTTP (remote servers, Cloud Run, Smithery.ai)
30
+ sse — Server-Sent Events (older remote servers; use http when possible)
31
+
32
+ Header values support ${ENV_VAR} substitution from environment.
33
+
34
+ Requires: pip install gemcode[mcp]
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import re
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from gemcode.config import GemCodeConfig
46
+
47
+ _ENV_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
48
+
49
+
50
+ def _expand_env(value: str) -> str:
51
+ """Expand ${VAR} placeholders from environment variables."""
52
+ return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), m.group(0)), value)
53
+
54
+
55
+ def _expand_headers(headers: dict) -> dict:
56
+ return {k: _expand_env(str(v)) for k, v in headers.items()}
57
+
58
+
59
+ def load_mcp_toolsets(cfg: GemCodeConfig) -> list:
60
+ path = cfg.project_root / ".gemcode" / "mcp.json"
61
+ if not path.is_file():
62
+ return []
63
+ try:
64
+ data = json.loads(path.read_text(encoding="utf-8"))
65
+ except json.JSONDecodeError as e:
66
+ raise ValueError(f"Invalid mcp.json: {e}") from e
67
+
68
+ try:
69
+ from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
70
+ from mcp import StdioServerParameters
71
+ except ImportError as e:
72
+ raise ImportError("Install MCP extras: pip install gemcode[mcp]") from e
73
+
74
+ servers = data.get("servers") or []
75
+ toolsets: list[Any] = []
76
+
77
+ for s in servers:
78
+ prefix = s.get("name") or "mcp"
79
+ tool_filter: list[str] | None = s.get("tools") or None # optional allowlist
80
+
81
+ # ── stdio (local subprocess) ─────────────────────────────────────────
82
+ if "stdio" in s:
83
+ stdio = s["stdio"]
84
+ cmd = stdio.get("command")
85
+ args = stdio.get("args") or []
86
+ env_extra = {k: _expand_env(str(v)) for k, v in (stdio.get("env") or {}).items()}
87
+ if not cmd:
88
+ continue
89
+ params = StdioServerParameters(
90
+ command=cmd,
91
+ args=args,
92
+ env={**os.environ, **env_extra} if env_extra else None,
93
+ )
94
+ kw: dict[str, Any] = dict(connection_params=params, tool_name_prefix=prefix)
95
+ if tool_filter:
96
+ kw["tool_filter"] = tool_filter
97
+ toolsets.append(McpToolset(**kw))
98
+ continue
99
+
100
+ # ── http (Streamable HTTP — modern, Cloud Run-friendly) ──────────────
101
+ if "http" in s:
102
+ http_cfg = s["http"]
103
+ url = _expand_env(http_cfg.get("url", ""))
104
+ if not url:
105
+ continue
106
+ headers = _expand_headers(http_cfg.get("headers") or {})
107
+ try:
108
+ from google.adk.tools.mcp_tool.mcp_session_manager import (
109
+ StreamableHTTPConnectionParams,
110
+ )
111
+ params_http = StreamableHTTPConnectionParams(url=url, headers=headers or None)
112
+ kw = dict(connection_params=params_http, tool_name_prefix=prefix)
113
+ if tool_filter:
114
+ kw["tool_filter"] = tool_filter
115
+ toolsets.append(McpToolset(**kw))
116
+ except ImportError:
117
+ # Fallback: try SseConnectionParams for older ADK builds
118
+ try:
119
+ from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
120
+ params_sse_fb = SseConnectionParams(url=url, headers=headers or None)
121
+ kw = dict(connection_params=params_sse_fb, tool_name_prefix=prefix)
122
+ if tool_filter:
123
+ kw["tool_filter"] = tool_filter
124
+ toolsets.append(McpToolset(**kw))
125
+ except ImportError:
126
+ pass # ADK version doesn't support remote MCP — skip silently
127
+ continue
128
+
129
+ # ── sse (Server-Sent Events — older remote servers) ──────────────────
130
+ if "sse" in s:
131
+ sse_cfg = s["sse"]
132
+ url = _expand_env(sse_cfg.get("url", ""))
133
+ if not url:
134
+ continue
135
+ headers = _expand_headers(sse_cfg.get("headers") or {})
136
+ try:
137
+ from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
138
+ params_sse = SseConnectionParams(url=url, headers=headers or None)
139
+ kw = dict(connection_params=params_sse, tool_name_prefix=prefix)
140
+ if tool_filter:
141
+ kw["tool_filter"] = tool_filter
142
+ toolsets.append(McpToolset(**kw))
143
+ except ImportError:
144
+ pass # ADK version too old — skip
145
+ continue
146
+
147
+ return toolsets
@@ -0,0 +1,127 @@
1
+ """
2
+ Auto-loads OpenAPI specs from `.gemcode/openapi/` as ADK OpenAPIToolset instances.
3
+
4
+ Inspired by the ADK community pattern: drop an OpenAPI spec in a directory and
5
+ the agent automatically gets REST API tools for it — no manual tool wiring needed.
6
+
7
+ Supported formats: .yaml, .yml, .json
8
+
9
+ Example:
10
+ .gemcode/openapi/
11
+ ├── github.yaml → tools: mcp__github__list_repos, mcp__github__create_pr, …
12
+ ├── sentry.json → tools: mcp__sentry__list_issues, …
13
+ └── internal_api.yaml → tools from your company's internal REST API
14
+
15
+ The filename (without extension) is used as the tool name prefix.
16
+ So `github.yaml` → tools prefixed with `github_`.
17
+
18
+ Auth:
19
+ Each spec file can have a matching `.auth` sidecar file with JSON:
20
+ {
21
+ "type": "api_key", // "api_key" | "bearer" | "basic" | "oauth2"
22
+ "header": "X-API-Key",
23
+ "value": "${GITHUB_TOKEN}" // ${ENV_VAR} is expanded from environment
24
+ }
25
+ For oauth2: {"type": "oauth2", "token_url": "...", "client_id": "...", "client_secret": "${VAR}"}
26
+
27
+ Requires: pip install google-adk (OpenAPIToolset is included in the main package)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ import os
35
+ import re
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ _ENV_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
42
+ _OPENAPI_DIR = ".gemcode/openapi"
43
+
44
+
45
+ def _expand(value: str) -> str:
46
+ return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), m.group(0)), value)
47
+
48
+
49
+ def _load_auth(spec_path: Path):
50
+ """Try to load auth config from <spec_stem>.auth next to the spec file."""
51
+ auth_path = spec_path.with_suffix(".auth")
52
+ if not auth_path.is_file():
53
+ auth_path = spec_path.parent / (spec_path.stem + ".auth.json")
54
+ if not auth_path.is_file():
55
+ return None, None
56
+ try:
57
+ auth_data = json.loads(auth_path.read_text())
58
+ except Exception:
59
+ return None, None
60
+
61
+ auth_type = auth_data.get("type", "").lower()
62
+ try:
63
+ from google.adk.auth import AuthCredential, AuthCredentialTypes
64
+ if auth_type == "api_key":
65
+ return None, AuthCredential(
66
+ auth_type=AuthCredentialTypes.API_KEY,
67
+ api_key=_expand(auth_data.get("value", "")),
68
+ )
69
+ if auth_type in ("bearer", "token"):
70
+ return None, AuthCredential(
71
+ auth_type=AuthCredentialTypes.HTTP,
72
+ http={"scheme": "bearer", "token": _expand(auth_data.get("value", ""))},
73
+ )
74
+ except ImportError:
75
+ pass
76
+ return None, None
77
+
78
+
79
+ def load_openapi_toolsets(project_root: Path) -> list[Any]:
80
+ """
81
+ Scan .gemcode/openapi/ for spec files and return OpenAPIToolset instances.
82
+
83
+ Returns an empty list if the directory doesn't exist or no valid specs are found.
84
+ Errors loading individual specs are logged but don't crash GemCode.
85
+ """
86
+ openapi_dir = project_root / _OPENAPI_DIR
87
+ if not openapi_dir.is_dir():
88
+ return []
89
+
90
+ try:
91
+ from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import (
92
+ OpenAPIToolset,
93
+ )
94
+ except ImportError:
95
+ log.debug("[openapi] OpenAPIToolset not available in this ADK version — skipping")
96
+ return []
97
+
98
+ toolsets: list[Any] = []
99
+
100
+ for spec_file in sorted(openapi_dir.iterdir()):
101
+ if spec_file.suffix.lower() not in (".yaml", ".yml", ".json"):
102
+ continue
103
+ if spec_file.stem.endswith(".auth"):
104
+ continue
105
+
106
+ prefix = spec_file.stem # e.g. "github" from "github.yaml"
107
+ fmt = "json" if spec_file.suffix.lower() == ".json" else "yaml"
108
+
109
+ try:
110
+ spec_str = spec_file.read_text(encoding="utf-8", errors="replace")
111
+ except OSError as e:
112
+ log.warning("[openapi] Could not read %s: %s", spec_file.name, e)
113
+ continue
114
+
115
+ _, auth_credential = _load_auth(spec_file)
116
+
117
+ try:
118
+ kwargs: dict[str, Any] = dict(spec_str=spec_str, spec_str_type=fmt)
119
+ if auth_credential is not None:
120
+ kwargs["auth_credential"] = auth_credential
121
+ toolset = OpenAPIToolset(**kwargs)
122
+ toolsets.append(toolset)
123
+ log.info("[openapi] Loaded spec '%s' from %s", prefix, spec_file.name)
124
+ except Exception as exc:
125
+ log.warning("[openapi] Failed to load %s: %s", spec_file.name, exc)
126
+
127
+ return toolsets