gemcode 0.3.49__tar.gz → 0.3.51__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.49/src/gemcode.egg-info → gemcode-0.3.51}/PKG-INFO +1 -1
  2. {gemcode-0.3.49 → gemcode-0.3.51}/pyproject.toml +1 -1
  3. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/agent.py +111 -8
  4. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/session_runtime.py +76 -70
  5. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/bash.py +54 -13
  6. gemcode-0.3.51/src/gemcode/tools/edit.py +82 -0
  7. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/filesystem.py +27 -7
  8. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/search.py +18 -10
  9. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/todo.py +42 -8
  10. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tui/input_handler.py +17 -3
  11. {gemcode-0.3.49 → gemcode-0.3.51/src/gemcode.egg-info}/PKG-INFO +1 -1
  12. gemcode-0.3.49/src/gemcode/tools/edit.py +0 -53
  13. {gemcode-0.3.49 → gemcode-0.3.51}/LICENSE +0 -0
  14. {gemcode-0.3.49 → gemcode-0.3.51}/MANIFEST.in +0 -0
  15. {gemcode-0.3.49 → gemcode-0.3.51}/README.md +0 -0
  16. {gemcode-0.3.49 → gemcode-0.3.51}/setup.cfg +0 -0
  17. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/__init__.py +0 -0
  18. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/__main__.py +0 -0
  19. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/audit.py +0 -0
  20. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/autocompact.py +0 -0
  21. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/callbacks.py +0 -0
  22. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/capability_routing.py +0 -0
  23. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/cli.py +0 -0
  24. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/compaction.py +0 -0
  25. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/computer_use/__init__.py +0 -0
  26. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/computer_use/browser_computer.py +0 -0
  27. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/config.py +0 -0
  28. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/context_budget.py +0 -0
  29. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/context_warning.py +0 -0
  30. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/credentials.py +0 -0
  31. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/hitl_session.py +0 -0
  32. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/hooks.py +0 -0
  33. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/intent_classifier.py +0 -0
  34. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/interactions.py +0 -0
  35. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/invoke.py +0 -0
  36. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/kairos_daemon.py +0 -0
  37. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/limits.py +0 -0
  38. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/live_audio_engine.py +0 -0
  39. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/logging_config.py +0 -0
  40. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/mcp_loader.py +0 -0
  41. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/memory/__init__.py +0 -0
  42. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/memory/embedding_memory_service.py +0 -0
  43. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/memory/file_memory_service.py +0 -0
  44. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/modality_tools.py +0 -0
  45. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/model_errors.py +0 -0
  46. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/model_routing.py +0 -0
  47. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/openapi_loader.py +0 -0
  48. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/paths.py +0 -0
  49. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/permissions.py +0 -0
  50. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/plugins/__init__.py +0 -0
  51. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  52. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  53. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/pricing.py +0 -0
  54. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/prompt_suggestions.py +0 -0
  55. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/__init__.py +0 -0
  56. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/config.py +0 -0
  57. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/deps.py +0 -0
  58. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/engine.py +0 -0
  59. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/stop_hooks.py +0 -0
  60. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/token_budget.py +0 -0
  61. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/query/transitions.py +0 -0
  62. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/refine.py +0 -0
  63. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/repl_commands.py +0 -0
  64. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/repl_slash.py +0 -0
  65. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/review_agent.py +0 -0
  66. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/session_store.py +0 -0
  67. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/slash_commands.py +0 -0
  68. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/thinking.py +0 -0
  69. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tool_prompt_manifest.py +0 -0
  70. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tool_registry.py +0 -0
  71. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/__init__.py +0 -0
  72. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/browser.py +0 -0
  73. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/notes.py +0 -0
  74. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/shell.py +0 -0
  75. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/shell_gate.py +0 -0
  76. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/subtask.py +0 -0
  77. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/think.py +0 -0
  78. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools/web.py +0 -0
  79. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tools_inspector.py +0 -0
  80. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/trust.py +0 -0
  81. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tui/scrollback.py +0 -0
  82. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tui/spinner.py +0 -0
  83. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tui/welcome_banner.py +0 -0
  84. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/tui/welcome_rich.py +0 -0
  85. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/version.py +0 -0
  86. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/vertex.py +0 -0
  87. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/web/__init__.py +0 -0
  88. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/web/claude_sse_adapter.py +0 -0
  89. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/web/terminal_repl.py +0 -0
  90. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode/workspace_hints.py +0 -0
  91. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode.egg-info/SOURCES.txt +0 -0
  92. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode.egg-info/dependency_links.txt +0 -0
  93. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode.egg-info/entry_points.txt +0 -0
  94. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode.egg-info/requires.txt +0 -0
  95. {gemcode-0.3.49 → gemcode-0.3.51}/src/gemcode.egg-info/top_level.txt +0 -0
  96. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_agent_instruction.py +0 -0
  97. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_autocompact.py +0 -0
  98. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_capability_routing.py +0 -0
  99. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_claude_web_adapter_sse.py +0 -0
  100. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_cli_init.py +0 -0
  101. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_computer_use_permissions.py +0 -0
  102. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_context_budget.py +0 -0
  103. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_context_warning.py +0 -0
  104. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_credentials.py +0 -0
  105. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_interactive_permission_ask.py +0 -0
  106. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_kairos_scheduler.py +0 -0
  107. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_modality_tools.py +0 -0
  108. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_model_error_retry.py +0 -0
  109. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_model_errors.py +0 -0
  110. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_model_routing.py +0 -0
  111. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_paths.py +0 -0
  112. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_permissions.py +0 -0
  113. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_prompt_suggestions.py +0 -0
  114. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_repl_commands.py +0 -0
  115. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_repl_slash.py +0 -0
  116. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_slash_commands.py +0 -0
  117. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_thinking_config.py +0 -0
  118. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_token_budget.py +0 -0
  119. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_tool_context_circulation.py +0 -0
  120. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_tools.py +0 -0
  121. {gemcode-0.3.49 → gemcode-0.3.51}/tests/test_tools_inspector.py +0 -0
  122. {gemcode-0.3.49 → gemcode-0.3.51}/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.49
3
+ Version: 0.3.51
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.49"
7
+ version = "0.3.51"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -122,13 +122,68 @@ def _load_gemini_md(project_root: Path) -> str:
122
122
  return combined[:_TOTAL_CAP]
123
123
 
124
124
 
125
+ def _get_git_context(root) -> str:
126
+ """
127
+ Run a quick git snapshot at session start — branch, recent commits, diff-stat.
128
+ Returns a formatted string or empty string if not a git repo.
129
+ Mirrors OpenClaude's getGitStatus() pattern.
130
+ """
131
+ import subprocess
132
+ import shutil
133
+
134
+ git = shutil.which("git")
135
+ if not git:
136
+ return ""
137
+ try:
138
+ def _run(*args, cwd=root):
139
+ r = subprocess.run(
140
+ [git, "--no-optional-locks"] + list(args),
141
+ cwd=str(cwd),
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=5,
145
+ )
146
+ return r.stdout.strip() if r.returncode == 0 else ""
147
+
148
+ # Check it's a git repo
149
+ if not _run("rev-parse", "--is-inside-work-tree"):
150
+ return ""
151
+
152
+ branch = _run("rev-parse", "--abbrev-ref", "HEAD") or "HEAD"
153
+ log = _run("log", "--oneline", "-5")
154
+ status = _run("status", "--short")
155
+ username = _run("config", "user.name")
156
+
157
+ if not log: # empty repo
158
+ return ""
159
+
160
+ status_trunc = status[:2000] + "\n(truncated)" if len(status) > 2000 else status
161
+
162
+ lines = [
163
+ "This is the git state at session start — it is a snapshot and will NOT update automatically.",
164
+ f"Current branch: {branch}",
165
+ ]
166
+ if username:
167
+ lines.append(f"Git user: {username}")
168
+ lines.append(f"Recent commits:\n{log}")
169
+ if status_trunc:
170
+ lines.append(f"Working tree status:\n{status_trunc}")
171
+ else:
172
+ lines.append("Working tree: clean")
173
+ return "\n\n".join(lines)
174
+ except Exception:
175
+ return ""
176
+
177
+
125
178
  def _build_runtime_facts(cfg: GemCodeConfig) -> str:
126
179
  """
127
180
  Injected every session so the model is fully self-aware of its own capabilities,
128
181
  limits, and the environment — not just generic defaults.
129
182
  """
183
+ import datetime
130
184
  root = cfg.project_root.resolve()
131
185
  model = (getattr(cfg, "model", None) or "").strip() or "(default)"
186
+ today = datetime.date.today().strftime("%A, %B %d, %Y")
132
187
 
133
188
  # ── Active capabilities ──────────────────────────────────────────────────
134
189
  caps: list[str] = []
@@ -178,7 +233,12 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
178
233
  "would benefit from background parallelism."
179
234
  )
180
235
 
236
+ # ── Git context ───────────────────────────────────────────────────────────
237
+ git_ctx = _get_git_context(root)
238
+ git_section = f"\n\n## Git context (snapshot at session start)\n{git_ctx}" if git_ctx else ""
239
+
181
240
  return f"""## Runtime facts (authoritative for this session)
241
+ - **Today's date:** {today}
182
242
  - **Project root** — every filesystem tool path is relative to: `{root}`
183
243
  - **Model id in use:** `{model}`. Override mid-session with `/model use <id>` or `/mode fast|balanced|quality|auto`.
184
244
  - **Execution budget:** {budget_line}.
@@ -190,7 +250,7 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
190
250
  {kairos_section}
191
251
  - **UI banner** phrases like "GemCode Pro" are terminal marketing, not a separate API tier.
192
252
  - **Env toggles** (`GEMCODE_ENABLE_COMPUTER_USE`, `GEMCODE_MODEL`, etc.) affect only the OS process that launched gemcode. Pasting `VAR=1` in chat does NOT reconfigure a running session—tell the user to export in their shell, use project `.env`, or restart the CLI.
193
- - **Working in subfolders** — call `list_directory("Desktop")`, `glob_files("**/query.ts")`, `read_file("testing/ai-edtech-app/src/app/page.tsx")` directly. Never claim access is blocked unless a tool returned an explicit error."""
253
+ - **Working in subfolders** — call `list_directory("Desktop")`, `glob_files("**/query.ts")`, `read_file("testing/ai-edtech-app/src/app/page.tsx")` directly. Never claim access is blocked unless a tool returned an explicit error.{git_section}"""
194
254
 
195
255
 
196
256
  def _build_memory_section(cfg: GemCodeConfig) -> str:
@@ -527,13 +587,16 @@ One user message = many model↔tool rounds (up to 256 LLM calls by default). Th
527
587
 
528
588
  **Do not stop after step 2 or 3** — complete the full task.
529
589
 
530
- ## Parallelism
531
- Issue independent tool calls in the same turn when outputs don't depend on each other:
532
- - Reading multiple files simultaneously
533
- - Grepping for different patterns at once
534
- - `list_directory` + `glob_files` in parallel ✓
535
- - Multiple `run_subtask` calls in one turn for parallel sub-agent exploration ✓
536
- Sequential: when step B needs step A's result.
590
+ ## Parallelism — batch independent work
591
+ Issue independent tool calls **in the same turn** when outputs don't depend on each other.
592
+ This is faster and costs fewer turns. Concrete examples:
593
+ - Reading multiple files send all `read_file` calls together
594
+ - Grepping different patterns → one message, multiple `grep_content` calls
595
+ - `list_directory` + `glob_files` issue both at once
596
+ - Exploring multiple subsystems one `run_subtask` per subsystem in one turn
597
+ - `git status` and `git log` → chain with `&&` or issue in parallel
598
+
599
+ Sequential only when step B genuinely needs step A's output.
537
600
 
538
601
  ## Sub-agent delegation (orchestrator-worker pattern)
539
602
  Use `run_subtask` when the work is better done in an isolated context:
@@ -595,11 +658,51 @@ For tasks where quality matters:
595
658
  - **Unexpected file content**: re-read the actual file rather than assuming your mental model is correct.
596
659
  - **Compiler / linter errors pasted by the user**: extract the file path and line from the error, read that file, apply the minimal fix, and re-run the check. Never explain without fixing.
597
660
 
661
+ ## Git Safety Protocol
662
+ Follow these rules on every turn, no exceptions:
663
+ - **NEVER** update git config
664
+ - **NEVER** run destructive git commands (`push --force`, `reset --hard`, `checkout .`, `restore .`, `clean -f`, `branch -D`) unless the user *explicitly* asks for it
665
+ - **NEVER** skip hooks (`--no-verify`, `--no-gpg-sign`) unless the user explicitly requests it
666
+ - **NEVER** force-push to main/master — warn the user if they ask for this
667
+ - **Prefer NEW commits over amending.** Only amend when all three conditions hold: (a) user explicitly asked, (b) the commit was created in this session, (c) it has NOT been pushed to remote. If a pre-commit hook rejects a commit, the commit did NOT happen — fix the problem and create a NEW commit, never amend.
668
+ - **Stage selectively** — prefer `git add <specific-file>` over `git add -A` or `git add .` to avoid accidentally including `.env`, credentials, or large binaries
669
+ - **Never commit unless the user explicitly asks.** It is very important to only commit when asked.
670
+
671
+ ## Committing changes
672
+ When the user asks for a git commit:
673
+ 1. Run in parallel: `git status`, `git diff`, `git log --oneline -5` (to match their style)
674
+ 2. Analyze all staged changes and draft a concise commit message (1-2 sentences, focus on *why* not *what*)
675
+ 3. Check for sensitive files (.env, credentials) — warn if they're staged
676
+ 4. Stage specific files, then commit via HEREDOC:
677
+ ```
678
+ git commit -m "$(cat <<'EOF'
679
+ Your message here.
680
+ EOF
681
+ )"
682
+ ```
683
+ 5. Run `git status` after to confirm success
684
+ 6. Do NOT push unless explicitly asked
685
+
686
+ ## Creating pull requests
687
+ Use `gh pr create` via `bash`. When asked to create a PR:
688
+ 1. Run in parallel: `git status`, `git diff`, `git log [base]...HEAD`, check remote tracking
689
+ 2. Look at ALL commits in the PR (not just the latest)
690
+ 3. Push branch if needed: `git push -u origin HEAD`
691
+ 4. Create with: `gh pr create --title "..." --body "$(cat <<'EOF'\n## Summary\n...\n## Test plan\n...\nEOF\n)"`
692
+ 5. Return the PR URL
693
+
598
694
  ## Risk and permissions
599
695
  - State destructive operations clearly before doing them (deletes, force-push, data truncation).
600
696
  - For `bash` commands that could be destructive (`rm -rf`, `git push --force`), confirm with the user first.
601
697
  - If a tool is denied, adjust the plan — don't retry the same gated call.
602
698
 
699
+ ## Avoid unnecessary sleep / polling
700
+ - Do NOT `sleep` between commands that can run immediately — just run them
701
+ - Do NOT poll a process in a sleep loop — check its status directly or start it with `background=True`
702
+ - If you're waiting for a background process you started, do not poll — it will complete on its own
703
+ - If you must wait (e.g. for a server to start), use a one-shot check: `bash("sleep 2 && curl -s http://localhost:3000")`
704
+ - Do NOT retry failing commands in a sleep loop — diagnose the root cause first
705
+
603
706
  ## Communication
604
707
  - One short line before the first tool call in a turn (e.g. "Reading the auth module and checking the test suite...").
605
708
  - Summarize tool results in plain language — the user doesn't see raw tool internals.
@@ -30,76 +30,82 @@ def session_db_path(cfg: GemCodeConfig) -> Path:
30
30
  return cfg.project_root / ".gemcode" / "sessions.sqlite"
31
31
 
32
32
 
33
- class _SafeComputerUseToolset:
33
+ def _make_safe_computer_toolset(computer):
34
34
  """
35
- Drop-in wrapper around ComputerUseToolset that catches Playwright startup
36
- failures (e.g. missing browser binary) and disables computer-use gracefully
37
- for the current session instead of crashing every turn with an unhandled error.
38
-
39
- When the underlying toolset fails for the *first* time a one-time warning is
40
- printed to stderr so the user knows they need to run ``playwright install``.
41
- Subsequent calls are silently no-ops so the session keeps working without
42
- browser tools — the agent can still use all other tools normally.
43
- """
44
-
45
- def __init__(self, computer) -> None:
46
- try:
47
- from google.adk.tools.computer_use.computer_use_toolset import ComputerUseToolset
48
- self._inner = ComputerUseToolset(computer=computer)
49
- except Exception:
50
- self._inner = None
51
- self._broken = False
52
- self._warned = False
53
-
54
- def _warn_once(self, error: Exception) -> None:
55
- if self._warned:
56
- return
57
- self._warned = True
58
- import sys
59
- msg = str(error)
60
- if "playwright install" in msg.lower() or "executable doesn't exist" in msg.lower():
61
- print(
62
- "\n[gemcode] Browser (computer-use) is unavailable — Playwright browsers are not installed.\n"
63
- " Run: playwright install chromium\n"
64
- " Then restart GemCode with /computer on (or --computer flag).\n"
65
- " Continuing without browser tools for this session.\n",
66
- file=sys.stderr,
67
- )
68
- else:
69
- print(
70
- f"\n[gemcode] Browser (computer-use) failed to start: {msg!s:.200}\n"
71
- " Continuing without browser tools for this session.\n",
72
- file=sys.stderr,
73
- )
35
+ Build a BaseToolset-compatible wrapper around ComputerUseToolset that catches
36
+ Playwright startup failures gracefully instead of crashing LlmAgent validation.
74
37
 
75
- # ── ADK toolset protocol ────────────────────────────────────────────────────
38
+ Must be a proper BaseToolset subclass (not a plain class) because ADK's Pydantic
39
+ model for LlmAgent validates each entry in `tools` against BaseTool | BaseToolset.
40
+ Returns a real BaseToolset subclass instance, or None if BaseToolset is unavailable.
41
+ """
42
+ try:
43
+ from google.adk.tools.base_toolset import BaseToolset
44
+ except ImportError:
45
+ return None
76
46
 
77
- async def process_llm_request(self, *, tool_context, llm_request) -> None:
78
- if self._broken or self._inner is None:
79
- return
80
- try:
81
- await self._inner.process_llm_request(
82
- tool_context=tool_context, llm_request=llm_request
83
- )
84
- except Exception as exc:
85
- self._broken = True
86
- self._warn_once(exc)
87
-
88
- async def get_tools(self, readonly_context=None):
89
- if self._broken or self._inner is None:
90
- return []
91
- try:
92
- return await self._inner.get_tools(readonly_context)
93
- except Exception as exc:
94
- self._broken = True
95
- self._warn_once(exc)
96
- return []
97
-
98
- def __getattr__(self, name: str):
99
- """Proxy all other attribute accesses to the inner toolset."""
100
- if self._inner is not None:
101
- return getattr(self._inner, name)
102
- raise AttributeError(name)
47
+ class _SafeComputerUseToolset(BaseToolset):
48
+ """Wraps ComputerUseToolset; degrades to a no-op if Playwright is missing."""
49
+
50
+ def __init__(self) -> None:
51
+ try:
52
+ from google.adk.tools.computer_use.computer_use_toolset import ComputerUseToolset
53
+ self._inner = ComputerUseToolset(computer=computer)
54
+ except Exception:
55
+ self._inner = None
56
+ self._broken = False
57
+ self._warned = False
58
+
59
+ def _warn_once(self, error: Exception) -> None:
60
+ if self._warned:
61
+ return
62
+ self._warned = True
63
+ import sys
64
+ msg = str(error)
65
+ if "playwright install" in msg.lower() or "executable doesn't exist" in msg.lower():
66
+ print(
67
+ "\n[gemcode] Browser (computer-use) is unavailable — Playwright browsers are not installed.\n"
68
+ " Run: playwright install chromium\n"
69
+ " Then restart GemCode with /computer on (or --computer flag).\n"
70
+ " Continuing without browser tools for this session.\n",
71
+ file=sys.stderr,
72
+ )
73
+ else:
74
+ print(
75
+ f"\n[gemcode] Browser (computer-use) failed to start: {msg!s:.200}\n"
76
+ " Continuing without browser tools for this session.\n",
77
+ file=sys.stderr,
78
+ )
79
+
80
+ async def process_llm_request(self, *, tool_context, llm_request) -> None:
81
+ if self._broken or self._inner is None:
82
+ return
83
+ try:
84
+ await self._inner.process_llm_request(
85
+ tool_context=tool_context, llm_request=llm_request
86
+ )
87
+ except Exception as exc:
88
+ self._broken = True
89
+ self._warn_once(exc)
90
+
91
+ async def get_tools(self, readonly_context=None):
92
+ if self._broken or self._inner is None:
93
+ return []
94
+ try:
95
+ return await self._inner.get_tools(readonly_context)
96
+ except Exception as exc:
97
+ self._broken = True
98
+ self._warn_once(exc)
99
+ return []
100
+
101
+ async def close(self) -> None:
102
+ if self._inner is not None:
103
+ try:
104
+ await self._inner.close()
105
+ except Exception:
106
+ pass
107
+
108
+ return _SafeComputerUseToolset()
103
109
 
104
110
 
105
111
  def _build_artifact_service(cfg: GemCodeConfig):
@@ -158,15 +164,15 @@ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner
158
164
  viewport_w = int(os.environ.get("GEMCODE_BROWSER_WIDTH", "1280"))
159
165
  viewport_h = int(os.environ.get("GEMCODE_BROWSER_HEIGHT", "720"))
160
166
  from gemcode.computer_use.browser_computer import BrowserComputer
161
- from google.adk.tools.computer_use.computer_use_toolset import ComputerUseToolset
162
167
 
163
168
  computer = BrowserComputer(
164
169
  headless=headless,
165
170
  viewport_size=(viewport_w, viewport_h),
166
171
  )
167
- computer_toolset = _SafeComputerUseToolset(computer=computer)
172
+ computer_toolset = _make_safe_computer_toolset(computer)
168
173
  merged_extra_tools = list(merged_extra_tools or [])
169
- merged_extra_tools.append(computer_toolset)
174
+ if computer_toolset is not None:
175
+ merged_extra_tools.append(computer_toolset)
170
176
 
171
177
  # Standalone read-only browser tools (browser_screenshot, browser_get_text, etc.)
172
178
  from gemcode.tools.browser import build_browser_inspection_tools
@@ -40,19 +40,60 @@ def make_bash_tool(cfg: GemCodeConfig):
40
40
  Run an arbitrary shell command via bash. Supports pipelines, redirects,
41
41
  subshells, and multi-step workflows that run_command cannot express.
42
42
 
43
- Use this for:
44
- - Git operations: bash("git log --oneline -20")
45
- - Pipelines: bash("cat package.json | python3 -m json.tool")
46
- - Finding files: bash("find . -name '*.py' -newer setup.py | head -20")
47
- - Complex builds: bash("cd frontend && npm ci && npm run build")
48
- - Inspecting output: bash("ls -la | grep '.py'")
49
- - Running tests with flags: bash("pytest -x -q --tb=short 2>&1 | head -100")
50
-
51
- For long-running servers use background=True. cwd_subdir sets working
52
- directory relative to the project root.
53
-
54
- IMPORTANT: This runs real shell commands. Be precise and avoid destructive
55
- operations (rm -rf, force-push, etc.) without explicit user approval.
43
+ ## Common usage patterns
44
+
45
+ Git / version control:
46
+ bash("git log --oneline -20")
47
+ bash("git diff HEAD~1 -- src/api/")
48
+ bash("git status && git diff --stat")
49
+ bash("git stash list")
50
+
51
+ Builds / tests:
52
+ bash("npm run build 2>&1 | tail -50")
53
+ bash("pytest tests/ -x -q --tb=short 2>&1 | head -150")
54
+ bash("cargo build --release 2>&1 | tail -30")
55
+ bash("go test ./... 2>&1 | tail -50")
56
+
57
+ Pipelines and inspection:
58
+ bash("find . -name '*.py' | xargs grep -l 'SomeClass' | head -20")
59
+ bash("cat package.json | python3 -m json.tool")
60
+ bash("wc -l $(find . -name '*.py') | sort -n | tail -20")
61
+
62
+ Long-running servers — ALWAYS use background=True:
63
+ bash("npm run dev", background=True)
64
+ bash("python manage.py runserver", background=True)
65
+ bash("tail -f logs/app.log", background=True)
66
+ NEVER call bash("npm run dev") without background=True — it blocks forever.
67
+
68
+ ## Issuing multiple commands
69
+ When commands are independent, issue them in separate parallel tool calls
70
+ in the same turn. When they depend on each other, chain with && in one call.
71
+ Use ; only when you don't care if earlier commands fail.
72
+ DO NOT use newlines to separate commands (newlines are ok inside quoted strings).
73
+
74
+ ## Git Safety Protocol — always follow these rules
75
+ - NEVER update git config
76
+ - NEVER run destructive git commands (push --force, reset --hard, checkout .,
77
+ restore ., clean -f, branch -D) without explicit user instruction
78
+ - NEVER skip hooks (--no-verify, --no-gpg-sign) unless the user explicitly asks
79
+ - NEVER force-push to main/master — warn the user if they request it
80
+ - ALWAYS prefer creating a NEW commit over amending an existing one.
81
+ Amending is ONLY appropriate when: (a) the user explicitly asks for it AND
82
+ (b) the commit has not been pushed to a remote yet
83
+ - When staging files, prefer adding specific files by name rather than
84
+ "git add -A" or "git add ." which can include .env or credentials
85
+ - Do NOT commit unless the user explicitly asks you to
86
+
87
+ ## Avoid unnecessary sleep
88
+ - Do NOT sleep between commands that can run immediately — just run them
89
+ - Do NOT poll in a sleep loop — check process status directly or use background=True
90
+ - If waiting for a background task, do not sleep-poll; it was started and will finish
91
+ - If you must wait (rate limits, deliberate pacing), keep it under 5 seconds
92
+
93
+ ## Security
94
+ Be precise. Avoid destructive operations (rm -rf, force-push) without
95
+ explicit user approval. Quote file paths that contain spaces.
96
+ cwd_subdir is relative to the project root.
56
97
  """
57
98
  if not trusted:
58
99
  return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
@@ -0,0 +1,82 @@
1
+ """Write and search_replace tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from gemcode.config import GemCodeConfig
8
+ from gemcode.paths import PathEscapeError, resolve_under_root
9
+
10
+
11
+ def make_edit_tools(cfg: GemCodeConfig):
12
+ root = cfg.project_root
13
+
14
+ def write_file(path: str, content: str) -> dict:
15
+ """
16
+ Create or overwrite a file with the given content.
17
+
18
+ Path is relative to the project root.
19
+
20
+ IMPORTANT: If the file already exists, READ it first with read_file() before
21
+ calling write_file(). Overwriting without reading risks losing existing content.
22
+ Only use write_file for NEW files or when you intend a complete replacement.
23
+ For targeted in-place edits, use search_replace() instead.
24
+
25
+ Never write non-textual content (binary, base64 blobs) — those belong in
26
+ artifacts, not in source files.
27
+ """
28
+ try:
29
+ p = resolve_under_root(root, path)
30
+ except PathEscapeError as e:
31
+ return {"error": str(e)}
32
+ p.parent.mkdir(parents=True, exist_ok=True)
33
+ p.write_text(content, encoding="utf-8")
34
+ return {"path": path, "bytes_written": len(content.encode("utf-8"))}
35
+
36
+ def search_replace(
37
+ path: str,
38
+ old_string: str,
39
+ new_string: str,
40
+ replace_all: bool = False,
41
+ ) -> dict:
42
+ """
43
+ Perform an exact string replacement in a file.
44
+
45
+ IMPORTANT: You MUST call read_file() on the file at least once before calling
46
+ search_replace(). Editing a file you haven't read leads to wrong context
47
+ and broken changes.
48
+
49
+ Usage rules:
50
+ - old_string must match the file EXACTLY (whitespace, indentation, line endings).
51
+ The edit FAILS if old_string is not found, or if it appears more than once
52
+ and replace_all is False.
53
+ - Use the smallest old_string that is clearly unique — typically 3-5 lines of
54
+ surrounding context is enough. Do not include 20+ lines of context when 4
55
+ lines would uniquely identify the target location.
56
+ - Set replace_all=True to rename a variable or rename a string across the whole file.
57
+ - Always prefer search_replace over write_file for targeted edits — it preserves
58
+ the rest of the file and makes the change reviewable.
59
+ - Do NOT add emojis or comments that just explain what the code does. Only add
60
+ comments that explain non-obvious intent or trade-offs.
61
+ - NEVER propose edits before reading. Read first. Edit second.
62
+ """
63
+ try:
64
+ p = resolve_under_root(root, path)
65
+ except PathEscapeError as e:
66
+ return {"error": str(e)}
67
+ if not p.is_file():
68
+ return {"error": f"Not a file: {path}"}
69
+ text = p.read_text(encoding="utf-8", errors="strict")
70
+ count = text.count(old_string)
71
+ if count == 0:
72
+ return {"error": "old_string not found"}
73
+ if count > 1 and not replace_all:
74
+ return {"error": f"old_string appears {count} times; set replace_all=true or narrow snippet"}
75
+ if replace_all:
76
+ new_text = text.replace(old_string, new_string)
77
+ else:
78
+ new_text = text.replace(old_string, new_string, 1)
79
+ p.write_text(new_text, encoding="utf-8")
80
+ return {"path": path, "replacements": count if replace_all else 1}
81
+
82
+ return write_file, search_replace
@@ -21,12 +21,19 @@ def make_filesystem_tools(cfg: GemCodeConfig):
21
21
  end_line: int | None = None,
22
22
  ) -> dict:
23
23
  """
24
- Read a text file relative to the project root. Large files are truncated.
24
+ Read a text file relative to the project root.
25
25
 
26
- Use start_line / end_line to read a specific line range (1-indexed, inclusive).
27
- This is efficient for large files e.g. read_file("app.py", start_line=100, end_line=200)
28
- reads only lines 100–200 without loading the whole file into context.
29
- Omit end_line to read from start_line to end of file (still subject to max_bytes).
26
+ IMPORTANT: ALWAYS use read_file before editing a file. Never propose changes
27
+ to code you haven't readthe mental model is always wrong without reading.
28
+ Never use bash("cat file") or bash("head file") use read_file instead.
29
+
30
+ For large files, use start_line / end_line to read a specific range (1-indexed, inclusive):
31
+ read_file("app.py", start_line=100, end_line=200) — lines 100-200
32
+ read_file("app.py", start_line=500) — line 500 to end
33
+ This is efficient — loads only the needed slice into context.
34
+
35
+ When multiple files are needed, issue all read_file calls in the same turn
36
+ (parallel reads) rather than sequentially.
30
37
  """
31
38
  if not trusted:
32
39
  return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
@@ -96,7 +103,13 @@ def make_filesystem_tools(cfg: GemCodeConfig):
96
103
  return {"src": src, "dest": dest, "moved": True}
97
104
 
98
105
  def list_directory(path: str = ".") -> dict:
99
- """List files and directories under path (relative to project root)."""
106
+ """
107
+ List files and directories under a path (relative to project root).
108
+
109
+ Use this instead of bash("ls ...") — it needs no permission and is instant.
110
+ Prefer for directory exploration before any editing or execution.
111
+ Issue in parallel with glob_files when you need both structure and file matches.
112
+ """
100
113
  if not trusted:
101
114
  return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
102
115
  try:
@@ -116,7 +129,14 @@ def make_filesystem_tools(cfg: GemCodeConfig):
116
129
  return {"path": path, "entries": entries[:500]}
117
130
 
118
131
  def glob_files(pattern: str) -> dict:
119
- """Glob file paths relative to project root (e.g. 'src/**/*.py')."""
132
+ """
133
+ Find files by glob pattern relative to project root (e.g. 'src/**/*.py').
134
+
135
+ Use this instead of bash("find . -name '*.py'") — it needs no permission.
136
+ Supports recursive patterns: '**/*.ts', 'src/**/test_*.py', '**/config*.json'.
137
+ Can be issued in parallel with list_directory and grep_content in the same turn.
138
+ Returns up to 200 matches.
139
+ """
120
140
  if not trusted:
121
141
  return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
122
142
  if ".." in pattern or pattern.startswith("/"):
@@ -38,20 +38,28 @@ def make_grep_tool(cfg: GemCodeConfig):
38
38
  case_sensitive: bool = True,
39
39
  ) -> dict:
40
40
  """
41
- Search file contents with a regex pattern.
41
+ Search file contents with a regex pattern (backed by ripgrep when available).
42
42
 
43
- Scans files matching path_glob (glob relative to project root). Binary files skipped.
43
+ Use this instead of bash("grep -r pattern .") it needs no permission
44
+ and is instant. Binary files are skipped automatically.
44
45
 
45
- Options:
46
- - context_lines: show N lines before and after each match (like grep -C N). Very useful for
47
- understanding surrounding code. Example: grep_content("def foo", context_lines=3)
48
- - case_sensitive: set False for case-insensitive search (like grep -i)
49
- - max_matches: cap on returned matches (1–500, default 80)
46
+ Parameters:
47
+ - pattern: Regex pattern (Python/ripgrep syntax). Use | for alternation.
48
+ - path_glob: File glob relative to project root (default: all files).
49
+ - context_lines: Lines before+after each match (like grep -C). Use to see
50
+ surrounding code e.g. context_lines=4 shows a function's body.
51
+ - case_sensitive: False for case-insensitive search.
52
+ - max_matches: Cap on returned results (1–500, default 80).
50
53
 
51
54
  Examples:
52
- grep_content("TODO", "**/*.py") # find all TODOs in Python files
53
- grep_content("useState", "**/*.tsx", context_lines=2) # React hooks with context
54
- grep_content("error", "**/*.log", case_sensitive=False) # case-insensitive log search
55
+ grep_content("def authenticate", "**/*.py", context_lines=4)
56
+ grep_content("TODO|FIXME|HACK", "**/*.ts")
57
+ grep_content("import React", "**/*.tsx", case_sensitive=False)
58
+ grep_content("class.*Error", "**/*.py", context_lines=2)
59
+ grep_content("useState", "src/**/*.tsx", context_lines=3)
60
+
61
+ Issue multiple grep_content calls in the same turn when searching for
62
+ different patterns — they run in parallel.
55
63
  """
56
64
  if max_matches < 1:
57
65
  max_matches = 1
@@ -21,14 +21,48 @@ def make_todo_tool(cfg: GemCodeConfig):
21
21
  tool_context: ToolContext,
22
22
  ) -> dict[str, Any]:
23
23
  """
24
- Maintain a task list for this session. Use for multi-step work: plan, then
25
- mark items completed as you go.
26
-
27
- Args:
28
- merge: If true, upsert by task id and keep prior order for existing ids;
29
- if false, replace the entire list.
30
- todos: Each item must have id (str), content (str), and status
31
- (pending | in_progress | completed | cancelled).
24
+ Create and maintain a structured task list for the current session.
25
+ Tracks progress, organises complex tasks, and makes multi-step work
26
+ visible to the user.
27
+
28
+ ## When to use
29
+ Use proactively when:
30
+ 1. Task has 3 or more distinct steps
31
+ 2. Task is non-trivial and requires careful planning
32
+ 3. User provides a list of things to do (numbered or comma-separated)
33
+ 4. After receiving new instructions — capture them as todos immediately
34
+ 5. When you start working on a sub-task — mark it in_progress BEFORE beginning.
35
+ Only one task should be in_progress at a time.
36
+ 6. After completing a sub-task — mark it completed and add any discovered follow-ups
37
+
38
+ ## When NOT to use
39
+ Skip this tool when:
40
+ 1. There is only one simple, straightforward task
41
+ 2. The task is trivial (can be done in 1-2 steps)
42
+ 3. The task is purely conversational or informational
43
+ 4. Answering a question that requires no planning
44
+
45
+ ## Verification
46
+ After completing a list of 3 or more tasks, if none of them was a verification
47
+ step, add a final verification task: "Verify all changes are correct and
48
+ consistent" — then actually do it (re-read key files, run tests, check imports).
49
+
50
+ ## Args
51
+ - merge: True = upsert by id (update specific items). False = replace entire list.
52
+ - todos: list of {id: str, content: str, status: pending|in_progress|completed|cancelled}
53
+
54
+ ## Examples
55
+ Good use (complex multi-step task):
56
+ todo_write(merge=False, todos=[
57
+ {"id":"1","content":"Read current auth.ts","status":"in_progress"},
58
+ {"id":"2","content":"Add JWT refresh logic","status":"pending"},
59
+ {"id":"3","content":"Update tests","status":"pending"},
60
+ {"id":"4","content":"Run npm run build to verify","status":"pending"},
61
+ ])
62
+
63
+ Bad use (single trivial task — just do it directly):
64
+ todo_write(merge=False, todos=[{"id":"1","content":"Fix typo in README","status":"pending"}])
65
+ # Don't do this. Just fix the typo.
32
66
  """
33
67
  if not isinstance(todos, list):
34
68
  return {"error": "todos must be a list"}
@@ -184,8 +184,18 @@ class GemCodeInputHandler:
184
184
  """Ctrl+C clears current line (like a real shell)."""
185
185
  event.app.current_buffer.reset()
186
186
 
187
- # Rows reserved for the / command popup. Too high (e.g. 20) leaves a huge
188
- # empty band above the prompt; 10–12 is enough for most slash lists.
187
+ @kb.add("enter")
188
+ def _submit(event):
189
+ """Enter always submits — even in multiline mode (for pasted code)."""
190
+ event.current_buffer.validate_and_handle()
191
+
192
+ @kb.add("escape", "enter", eager=True)
193
+ @kb.add("c-j") # Ctrl+J = manual newline inside a message
194
+ def _newline(event):
195
+ """Meta+Enter or Ctrl+J inserts a real newline without submitting."""
196
+ event.current_buffer.insert_text("\n")
197
+
198
+ # Rows reserved for the / command popup.
189
199
  try:
190
200
  _menu_rows = int(os.environ.get("GEMCODE_TUI_RESERVE_MENU_LINES", "12"))
191
201
  except ValueError:
@@ -202,8 +212,12 @@ class GemCodeInputHandler:
202
212
  mouse_support=False,
203
213
  complete_in_thread=True,
204
214
  reserve_space_for_menu=_menu_rows,
205
- # Single-column popup with description column (like VS Code)
206
215
  complete_style="COLUMN",
216
+ # Multiline=True so pasted code (with \n) lands in the buffer
217
+ # as one block rather than submitting line-by-line.
218
+ # Our Enter binding above overrides the default "add newline"
219
+ # behaviour so single-line prompts work exactly as before.
220
+ multiline=True,
207
221
  )
208
222
 
209
223
  def is_interactive(self) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.49
3
+ Version: 0.3.51
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -1,53 +0,0 @@
1
- """Write and search_replace tools."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- from gemcode.config import GemCodeConfig
8
- from gemcode.paths import PathEscapeError, resolve_under_root
9
-
10
-
11
- def make_edit_tools(cfg: GemCodeConfig):
12
- root = cfg.project_root
13
-
14
- def write_file(path: str, content: str) -> dict:
15
- """Create or overwrite a file relative to the project root."""
16
- try:
17
- p = resolve_under_root(root, path)
18
- except PathEscapeError as e:
19
- return {"error": str(e)}
20
- p.parent.mkdir(parents=True, exist_ok=True)
21
- p.write_text(content, encoding="utf-8")
22
- return {"path": path, "bytes_written": len(content.encode("utf-8"))}
23
-
24
- def search_replace(
25
- path: str,
26
- old_string: str,
27
- new_string: str,
28
- replace_all: bool = False,
29
- ) -> dict:
30
- """
31
- Replace old_string with new_string in a text file. Fails if old_string
32
- is missing or duplicate (unless replace_all=True).
33
- """
34
- try:
35
- p = resolve_under_root(root, path)
36
- except PathEscapeError as e:
37
- return {"error": str(e)}
38
- if not p.is_file():
39
- return {"error": f"Not a file: {path}"}
40
- text = p.read_text(encoding="utf-8", errors="strict")
41
- count = text.count(old_string)
42
- if count == 0:
43
- return {"error": "old_string not found"}
44
- if count > 1 and not replace_all:
45
- return {"error": f"old_string appears {count} times; set replace_all=true or narrow snippet"}
46
- if replace_all:
47
- new_text = text.replace(old_string, new_string)
48
- else:
49
- new_text = text.replace(old_string, new_string, 1)
50
- p.write_text(new_text, encoding="utf-8")
51
- return {"path": path, "replacements": count if replace_all else 1}
52
-
53
- return write_file, search_replace
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes