@allansantos-dev/smart-tool 0.9.43 → 0.9.46

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.46 - beta
4
+
5
+ - The interface speaks Portuguese and English. It follows the Windows display language (any Portuguese shows Brazilian
6
+ Portuguese, anything else English) until the user picks one: a globe button (PT/EN) in the header of the settings,
7
+ projects and map pages, and an "Idioma / Language" submenu in the tray, both saved as `language` in the config. The
8
+ settings and projects pages, including the text their scripts write, the tray menu, tooltip and notifications, the
9
+ Windows toasts, the folder picker and the update screen are translated; agent-facing text (tool answers, hook
10
+ messages) stays English. English is the catalog key (`locales/pt-BR.json`, read by `i18n.py`); the pages translate
11
+ their rendered text in the browser, and `tests/test_i18n.py` fails when a visible string has no entry.
12
+ - Updating never opens a console window. "Check for updates" now says "Checking for updates…" in a Windows notification,
13
+ and an update shows its own small window with no buttons (`update_splash.py`): the version change, the current step
14
+ (downloading, copying files, installing dependencies…) and the result, in the interface language. The install command
15
+ runs with no console, so npm, the installer, pip, PowerShell and schtasks share one hidden console; its output goes to
16
+ `~/.smart-tool/data/update.log`, and a failed update says so on the screen and in a toast with the log path.
17
+ - The tray menu no longer flashes console windows when it opens: its "Start with Windows" check ran `schtasks` from the
18
+ window-less tray without `CREATE_NO_WINDOW`.
19
+ - The tray tells "Smart Tool updated from X to Y" also when the daemon started first after the update: both record the
20
+ new running version, and the tray keeps its own marker (`tray_told`) instead of needing to be the one that found it.
21
+
22
+ ## 0.9.45 - beta
23
+
24
+ - The Playwright MCP server starts on first use. What the clients register is now `lazy_mcp_proxy.py` around
25
+ the real command: it answers `initialize`, `ping` and `tools/list` from the handshake the setup probe saved
26
+ (`playwright-mcp-handshake.json`) and starts the Node server on the first other request, replaying the client's
27
+ handshake to it, then passes everything through and kills the server's process tree when the client closes. Before, each
28
+ Claude session started its own Node server (about 97 MB of commit) whether or not it opened a browser. A missing or
29
+ broken cache fails loudly (exit 2, reason on stderr), never as an empty tool list; the heal at startup also checks the
30
+ real server's executable. Tests in `tests/test_lazy_mcp_proxy.py` and `tests/test_browser_control.py`.
31
+ - Web searches record who asked. Each provider call (`web-search-health.jsonl`) and each research record
32
+ (`research-metrics.jsonl`) carries `requester` (`client`, `mcp_session`, `session`, `project`, `source`): the PreToolUse
33
+ hook, which gets the Claude Code session and folder, now also matches `web_search` and `web_fetch`, and the MCP call
34
+ claims that note by tool and arguments. Without the hook (Codex, or a hook not yet updated) only the client and the MCP
35
+ session are known and `source` is `none`. A redirected native WebSearch/WebFetch is attributed from the hook payload,
36
+ and `integration-metrics.jsonl` carries the same field. The query text is stored nowhere new. Tests in
37
+ `tests/test_requester.py`. The startup heal also rebuilds a registration that still starts the Node server directly or
38
+ whose handshake cache is gone, and the proxy gives up on a real server silent for 60 s on its first `initialize`.
39
+ - The resident search browser's exit after 300 s without requests is now covered by a test that runs the real service
40
+ loop with a stub browser (`tests/test_browser_idle.py`); the value is unchanged.
41
+
42
+ ## 0.9.44 - beta
43
+
44
+ - New tool `read_document`: a PDF, Word, PowerPoint or Excel file read as compact text, a range at a time. The answer opens
45
+ with the type, size, page, slide, sheet or part count and the estimated tokens of all the text, then the text of the
46
+ `range` asked for (`3`, `3-5`, `7-`, `-4`, `1,4-6`, a sheet name) up to `max_chars`, with a `next:` line to continue;
47
+ `info_only` returns just the facts. PDF pages are compacted (ligatures, hyphenated line ends, wrapped lines); a docx is
48
+ markdown (headings, lists, GFM tables, notes, headers) in parts of about 6,000 characters, since it has no fixed pages;
49
+ a slide carries its speaker notes; a sheet is one tab-separated line per row with cached formula values. A scanned PDF,
50
+ a password, a corrupt or hostile file and a range outside the document fail with the reason. Opt-in: the native Read is
51
+ not redirected and the tool is not loaded upfront (the server instructions say to load it with ToolSearch). No new
52
+ dependency; the extractors of the index now return their units (`pdf_pages`, `pptx_slides`, `xlsx_sheets`) and the
53
+ indexed text is unchanged. Sources and the measurement in `docs/research/2026-10-30-read-document.md`; tests in
54
+ `tests/test_read_document.py`.
55
+
3
56
  ## 0.9.43 - beta
4
57
 
5
58
  - The session-start audit report (Claude Code and Codex) is also given after a compaction and after a fork, the two
package/README.md CHANGED
@@ -8,6 +8,7 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
8
8
  | `smart_scout` | Answers a question about the code with the lines that answer it: reads the candidate files inside Smart Tool and returns `path:lines` with the literal text, so the files never enter the agent's context; with a `scout_model`, a small model also writes a short answer and keeps only the lines that support it; a question about why something is as it is (why, reason, decided) also lists up to three decision notes from the notes the project keeps about its intent, as ids with one excerpt each |
9
9
  | `web_search` / `web_search_result` | Web search across several sources in parallel, in the query language and in English, with a cache shared across sessions; `depth="research"` runs a multi-round research loop |
10
10
  | `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h. Pages over 60,000 characters are not cut: the model reads the parts closest to the prompt (needs an `embedding_model`) |
11
+ | `read_document` | Reads a PDF, Word, PowerPoint or Excel file as compact text, a range at a time: type, size, page/slide/sheet count and estimated tokens first, then only the pages, slides or sheets asked for (`range="3-5"`), so the file never enters the agent's context whole. Opt-in: the native Read is not redirected. Scanned PDFs fail with that reason (no OCR) |
11
12
  | `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage; `graph` with `symbol` tells what changing a function touches: callers, calls, the tests that reach it and the files that import it |
12
13
 
13
14
  Agents that must click, type or log in also get Microsoft's [Playwright MCP](https://github.com/microsoft/playwright-mcp),
@@ -16,7 +17,7 @@ registered as the `playwright` server where this machine lets a browser be drive
16
17
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
17
18
  are cheaper, or just tells the agent what Smart Tool would do.
18
19
 
19
- **Status:** 0.9.43 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
+ **Status:** 0.9.46 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
21
 
21
22
  ## How it works
22
23
 
@@ -86,12 +87,17 @@ npx @allansantos-dev/smart-tool@latest install
86
87
  ```
87
88
 
88
89
  Updates install themselves, on by default: the tray checks npm when it starts (at logon) and every 24 h, and when there
89
- is a newer version it runs this same command in a visible console (once per version; a failed version is tried again
90
- after 24 h, with the previous version kept). You are told each time: a Windows notification from the tray (newer version,
90
+ is a newer version it runs this same command behind its own small update window, with no console window (once per
91
+ version; a failed version is tried again after 24 h, with the previous version kept). The window has no buttons: it shows
92
+ the version change, the current step and the result, and closes by itself; the command's output is kept in
93
+ `~/.smart-tool/data/update.log`. You are told each time: a Windows notification from the tray (newer version,
91
94
  updating, updated, update failed), and agent sessions get the same line on their first `smart_search` (and at session
92
95
  start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
93
96
  X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
94
- automatic updates off, and the newer-version notice then carries the command to run. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
97
+ automatic updates off, and the newer-version notice then carries the command to run. The interface (tray, notifications,
98
+ settings and projects pages, update window) is in Brazilian Portuguese or English: it follows the Windows display
99
+ language until you pick one with the globe button (PT/EN) at the top of the pages or the tray's "Idioma / Language"
100
+ submenu. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
95
101
  verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` and, in the registry, only two per-user (HKCU) keys that make the notification buttons work, then
96
102
  copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
97
103
  installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
@@ -149,7 +155,21 @@ Redirects) and Maintenance (Setup audit, Startup).
149
155
  `browser_run_code_unsafe`, which runs arbitrary JavaScript in the server process, is denied in the client
150
156
  (`permissions.deny` in `~/.claude/settings.json`, `disabled_tools` in `~/.codex/config.toml`); if it cannot be
151
157
  denied, the server is not registered. A `playwright` server you registered yourself is never touched. Remove it with
152
- `claude mcp remove playwright -s user` / `codex mcp remove playwright`.
158
+ `claude mcp remove playwright -s user` / `codex mcp remove playwright`. The registered command is a small stdio
159
+ proxy (`lazy_mcp_proxy.py`, standard library only) that answers the handshake and the tool list from a cache the
160
+ probe writes and starts the Node server on the first tool call, so a session that never opens a browser does not
161
+ keep a 97 MB Node process; it ends with the session.
162
+
163
+ **Who asked for a web search**: every provider call in `~/.smart-tool/data/web-search-health.jsonl` and every
164
+ record of `research-metrics.jsonl` made for a `web_search` carries `requester`: `client`, `mcp_session` (first 12
165
+ characters of the daemon's session id), `session` and `project` (the Claude Code session id and folder) and `source`
166
+ (`hook`: the PreToolUse hook saw this call; `mcp-session`: an earlier call of the same MCP session was attributed;
167
+ `none`: only the client name is known, which is what Codex gives, since its hook does not cover MCP tools). The
168
+ query text is not stored there. The folder and session come from the installed hook: after an update, "Update hook"
169
+ on the setup page adds `web_search` and `web_fetch` to its matcher. A native WebSearch or WebFetch that the hook
170
+ redirects is attributed from the hook payload itself (`mcp_session` empty). `integration-metrics.jsonl` carries the
171
+ same `requester` on the web events. A `deep` crawl job writes no provider record, so it has none. The hook's note
172
+ waits up to 60 s for its MCP call; an identical query from two sessions at once can swap their notes.
153
173
 
154
174
  The hook behaviors:
155
175
  - **Redirect** (default): the call is not denied. Smart Tool answers it and the answer comes back as the output
package/autostart.py CHANGED
@@ -14,6 +14,8 @@ import tempfile
14
14
  import toast_notice
15
15
 
16
16
  TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
17
+ # The tray runs under pythonw (no console): schtasks without this flag flashed a console each time its menu was drawn.
18
+ _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
17
19
 
18
20
 
19
21
  class AutostartError(Exception):
@@ -138,7 +140,7 @@ def _task_installed():
138
140
  try:
139
141
  result = subprocess.run(
140
142
  ["schtasks", "/Query", "/TN", TASK_NAME],
141
- capture_output=True, text=True, timeout=20,
143
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
142
144
  )
143
145
  except (OSError, subprocess.SubprocessError):
144
146
  return False
@@ -159,7 +161,7 @@ def install():
159
161
  f.write(xml.encode("utf-16"))
160
162
  result = subprocess.run(
161
163
  ["schtasks", "/Create", "/TN", TASK_NAME, "/XML", tmp_path, "/F"],
162
- capture_output=True, text=True, timeout=20,
164
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
163
165
  )
164
166
  finally:
165
167
  try:
@@ -182,7 +184,7 @@ def remove():
182
184
  try:
183
185
  result = subprocess.run(
184
186
  ["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
185
- capture_output=True, text=True, timeout=20,
187
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
186
188
  )
187
189
  if result.returncode == 0:
188
190
  removed.append("task")
@@ -7,6 +7,10 @@ as the agent would: machines whose policy forbids it (corporate IT blocking remo
7
7
  keep today's read-only behavior, and the reason is shown in the setup screen instead of an error. A server named
8
8
  `playwright` the user already registered is left untouched.
9
9
 
10
+ The registered command is lazy_mcp_proxy.py around the real server: every session starts the registered servers, and
11
+ a Node process kept idle in each session that never opens a browser cost about 97 MB of commit each. The proxy answers
12
+ the handshake from the cache the probe writes (HANDSHAKE_PATH) and starts the real server on the first tool call.
13
+
10
14
  Pages the agent opens are untrusted input. The server never exposes tools a page registers (WebMCP); every request
11
15
  the browser makes carries AGENT_HEADER, which the daemon refuses, so a page cannot steer the agent into Smart Tool's
12
16
  own setup page and its token (redirects included); `browser_run_code_unsafe` (arbitrary JavaScript in the server
@@ -18,6 +22,7 @@ import os
18
22
  import queue
19
23
  import re
20
24
  import subprocess
25
+ import sys
21
26
  import tempfile
22
27
  import threading
23
28
  import time
@@ -26,6 +31,7 @@ import tomllib
26
31
  import atomic_io
27
32
  import endpoint_sync
28
33
  import install_runtime
34
+ import lazy_mcp_proxy
29
35
  import node_locator
30
36
  import paths
31
37
 
@@ -34,6 +40,8 @@ CLI = install_runtime.PLAYWRIGHT_MCP_CLI
34
40
  STATUS_PATH = os.path.join(paths.DATA_DIR, "browser-control.json")
35
41
  OUTPUT_DIR = os.path.join(paths.DATA_DIR, "playwright-mcp")
36
42
  CONFIG_PATH = os.path.join(paths.DATA_DIR, "playwright-mcp.json")
43
+ HANDSHAKE_PATH = os.path.join(paths.DATA_DIR, "playwright-mcp-handshake.json")
44
+ PROXY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lazy_mcp_proxy.py")
37
45
  AGENT_HEADER = "X-Smart-Tool-Agent-Browser"
38
46
  OUTPUT_MAX_BYTES = 100 * 1024 * 1024
39
47
  PROBE_TIMEOUT_S = 60
@@ -68,6 +76,15 @@ def server_command():
68
76
  "--output-max-size", str(OUTPUT_MAX_BYTES)]
69
77
 
70
78
 
79
+ def lazy_command(command):
80
+ """The command registered in the clients: lazy_mcp_proxy.py (console Python of this installation) around command,
81
+ the real server line, answering from HANDSHAKE_PATH until a tool call needs the real one."""
82
+ python = sys.executable
83
+ if os.path.basename(python).lower() == "pythonw.exe":
84
+ python = os.path.join(os.path.dirname(python), "python.exe")
85
+ return [python, PROXY, "--handshake", HANDSHAKE_PATH, "--", *command]
86
+
87
+
71
88
  def write_config():
72
89
  """Writes the Playwright MCP config file that adds AGENT_HEADER to every request of the browser (navigations,
73
90
  redirects, subresources; measured: no CORS preflight is added) and returns its path."""
@@ -77,14 +94,16 @@ def write_config():
77
94
  return CONFIG_PATH
78
95
 
79
96
 
80
- def probe(command, timeout=PROBE_TIMEOUT_S):
97
+ def probe(command, timeout=PROBE_TIMEOUT_S, handshake_path=None):
81
98
  """Starts the server over stdio, opens about:blank and closes it, as an agent's first call would.
82
- Returns None when the browser opened, else the reason it did not (a policy block, a crash, a timeout)."""
99
+ Returns None when the browser opened, else the reason it did not (a policy block, a crash, a timeout). With
100
+ handshake_path, a successful probe also saves the server's initialize result and tool list there, the cache
101
+ lazy_mcp_proxy answers from."""
83
102
  with tempfile.TemporaryDirectory(prefix="st-browser-probe-", ignore_cleanup_errors=True) as workdir:
84
- return _probe(command, workdir, timeout)
103
+ return _probe(command, workdir, timeout, handshake_path)
85
104
 
86
105
 
87
- def _probe(command, workdir, timeout):
106
+ def _probe(command, workdir, timeout, handshake_path=None):
88
107
  """probe() inside workdir, the server's working folder."""
89
108
  flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
90
109
  try:
@@ -118,14 +137,20 @@ def _probe(command, workdir, timeout):
118
137
  return message["result"]
119
138
 
120
139
  try:
121
- call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
122
- "clientInfo": {"name": "smart-tool-probe", "version": "1"}})
140
+ initialized = call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
141
+ "clientInfo": {"name": "smart-tool-probe", "version": "1"}})
123
142
  proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
143
+ tools = call(4, "tools/list", {}).get("tools") if handshake_path else None
124
144
  result = call(2, "tools/call", {"name": "browser_navigate", "arguments": {"url": "about:blank"}})
125
145
  if result.get("isError"):
126
146
  text = " ".join(part.get("text", "") for part in result.get("content") or [])
127
147
  return f"browser did not open: {' '.join(text.split())[:300]}"
128
148
  call(3, "tools/call", {"name": "browser_close", "arguments": {}})
149
+ if handshake_path:
150
+ if not tools:
151
+ return "the server listed no tools"
152
+ os.makedirs(os.path.dirname(handshake_path), exist_ok=True)
153
+ atomic_io.write_secret_text(handshake_path, json.dumps({"initialize": initialized, "tools": tools}))
129
154
  return None
130
155
  except (OSError, RuntimeError, TimeoutError) as exc:
131
156
  return str(exc)
@@ -158,8 +183,9 @@ def registered():
158
183
  return found
159
184
 
160
185
 
161
- def registered_command(client):
162
- """Executable the client's `playwright` server is registered with, or None when it is not registered."""
186
+ def registered_executables(client):
187
+ """Executables the client's `playwright` server needs: the registered command and, behind lazy_mcp_proxy, the real
188
+ server's too; an empty list when it is not registered."""
163
189
  try:
164
190
  if client == "claude":
165
191
  with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
@@ -168,9 +194,32 @@ def registered_command(client):
168
194
  with open(_codex_config(), encoding="utf-8") as stream:
169
195
  entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
170
196
  except (OSError, ValueError, AttributeError):
171
- return None
197
+ return []
172
198
  command = entry.get("command") if isinstance(entry, dict) else None
173
- return command if isinstance(command, str) else None
199
+ if not isinstance(command, str):
200
+ return []
201
+ args = entry.get("args") if isinstance(entry.get("args"), list) else []
202
+ real = args[args.index("--") + 1] if "--" in args and args.index("--") + 1 < len(args) else None
203
+ return [command] + ([real] if isinstance(real, str) else [])
204
+
205
+
206
+ def _lazy_registration_usable(client):
207
+ """Whether the client's registration goes through lazy_mcp_proxy and the handshake cache it answers from is
208
+ readable: a registration of an older version (the Node server directly) or a lost cache both need ensure."""
209
+ try:
210
+ if client == "claude":
211
+ with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
212
+ entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
213
+ else:
214
+ with open(_codex_config(), encoding="utf-8") as stream:
215
+ entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
216
+ args = entry.get("args") or []
217
+ if PROXY not in args:
218
+ return False
219
+ lazy_mcp_proxy.load_handshake(HANDSHAKE_PATH)
220
+ except (OSError, ValueError, AttributeError, RuntimeError):
221
+ return False
222
+ return True
174
223
 
175
224
 
176
225
  def heal(log=None):
@@ -179,9 +228,10 @@ def heal(log=None):
179
228
  {client: ensure's outcome} for the clients refreshed."""
180
229
  refreshed = {}
181
230
  for client in ("claude", "codex"):
182
- command = registered_command(client)
231
+ executables = registered_executables(client)
183
232
  ours = status().get(client, {}).get("state") in ("registered", "undenied")
184
- if ours and command and not node_locator.exists(command):
233
+ if ours and executables and not (all(node_locator.exists(path) for path in executables)
234
+ and _lazy_registration_usable(client)):
185
235
  refreshed[client] = ensure(client, log)
186
236
  return refreshed
187
237
 
@@ -302,6 +352,12 @@ def _record(client, state, reason="", log=None):
302
352
  return data[client]
303
353
 
304
354
 
355
+ def _clip(text, limit=300):
356
+ """text within limit characters, keeping its start and its end: a failed CLI call names the long command first and
357
+ the CLI's own message last."""
358
+ return text if len(text) <= limit else text[:limit // 3] + " ... " + text[-(limit - limit // 3 - 5):]
359
+
360
+
305
361
  def ensure(client, log=None):
306
362
  """Registers Playwright MCP in the client, or refreshes the registration Smart Tool made before (Node, Chromium
307
363
  and install paths change between versions). A `playwright` server the user registered is kept. When the browser
@@ -315,7 +371,7 @@ def ensure(client, log=None):
315
371
  return _record(client, "kept", "a playwright MCP server you registered is kept")
316
372
  try:
317
373
  command = server_command()
318
- problem = probe(command)
374
+ problem = probe(command, handshake_path=HANDSHAKE_PATH)
319
375
  except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
320
376
  problem = str(exc)
321
377
  try:
@@ -325,7 +381,7 @@ def ensure(client, log=None):
325
381
  if ours and client == "claude":
326
382
  set_claude_deny(False)
327
383
  return _record(client, "unavailable", problem[:300], log)
328
- endpoint_sync.run_cli(register_command(client, command))
384
+ endpoint_sync.run_cli(register_command(client, lazy_command(command)))
329
385
  if not registered().get(client):
330
386
  return _record(client, "unavailable", "the client CLI finished, but the server did not appear in its "
331
387
  "configuration", log)
@@ -342,5 +398,5 @@ def ensure(client, log=None):
342
398
  except (OSError, RuntimeError, ValueError) as exc:
343
399
  # Our registration still in place after a failure (its removal failed) stays ours, flagged, never "kept".
344
400
  state = "undenied" if ours and registered().get(client) else "unavailable"
345
- return _record(client, state, str(exc)[:300], log)
401
+ return _record(client, state, _clip(str(exc)), log)
346
402
  return _record(client, "registered")
package/client_hooks.py CHANGED
@@ -29,7 +29,7 @@ HOME = os.path.expanduser("~")
29
29
  PREVIEW_TTL_S = 600
30
30
  CLIENTS = {
31
31
  "claude": {"label": "Claude Code", "names": {"claude-code"}, "files": [os.path.join(endpoint_sync.claude_dir(), "settings.json")],
32
- "docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|PowerShell|WebSearch|WebFetch|Edit|MultiEdit|Write|mcp__smart-tool__smart_search", "http": True,
32
+ "docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|PowerShell|WebSearch|WebFetch|Edit|MultiEdit|Write|mcp__smart-tool__smart_search|mcp__smart-tool__web_search|mcp__smart-tool__web_fetch", "http": True,
33
33
  "post_matcher": "Grep|Bash|PowerShell|WebFetch|WebSearch"},
34
34
  "codex": {"label": "Codex", "prefix": "codex", "files": [os.path.join(endpoint_sync.codex_dir(), "hooks.json"), os.path.join(endpoint_sync.codex_dir(), "config.toml")],
35
35
  "docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash|apply_patch",
@@ -120,7 +120,7 @@ def _entry_changes(event, matcher, expected, groups):
120
120
  covered = str(group.get("matcher") or "").split("|")
121
121
  left_out = [tool for tool in matcher.split("|") if tool not in covered]
122
122
  if left_out:
123
- changes.append(f"Its {event} entry does not cover " + ", ".join(left_out) + ".")
123
+ changes.append(f"Its {event} entry does not cover {', '.join(left_out)}.")
124
124
  found = next(hook for hook in group["hooks"] if _our_hook(hook))
125
125
  if _target(found) != _target(expected):
126
126
  changes.append(f"It calls {_target(found)} instead of {_target(expected)}.")
package/config.py CHANGED
@@ -39,6 +39,7 @@ DEFAULT_CONFIG = {
39
39
  "duplicate_mode": "warn",
40
40
  "dependency_mode": "warn",
41
41
  "auto_update": True,
42
+ "language": "",
42
43
  "pattern_proposals": "on",
43
44
  "contact": "",
44
45
  "model_adapter": "",
@@ -132,6 +133,17 @@ def auto_update(cfg=None):
132
133
  return value
133
134
 
134
135
 
136
+ LANGUAGES = ("pt-BR", "en")
137
+
138
+
139
+ def language(cfg=None):
140
+ """Interface language the user picked ("pt-BR" or "en"), or "" to follow the Windows display language."""
141
+ value = (cfg if cfg is not None else load_config()).get("language", DEFAULT_CONFIG["language"])
142
+ if value not in ("", *LANGUAGES):
143
+ raise ValueError(f"Invalid language ({value!r}) in {CONFIG_PATH}: use one of {', '.join(LANGUAGES)} or \"\".")
144
+ return value
145
+
146
+
135
147
  def hook_mode(cfg=None):
136
148
  value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
137
149
  if value not in HOOK_MODES: