@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 +53 -0
- package/README.md +25 -5
- package/autostart.py +5 -3
- package/browser_control.py +71 -15
- package/client_hooks.py +2 -2
- package/config.py +12 -0
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/folder_picker.py +7 -5
- package/hook_decision.py +9 -1
- package/i18n.py +213 -0
- package/lazy_mcp_proxy.py +185 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +69 -10
- package/toast_notice.py +3 -2
- package/tool_redirect.py +2 -1
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
- package/web_search_health.py +5 -2
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.
|
|
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
|
|
90
|
-
after 24 h, with the previous version kept).
|
|
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.
|
|
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")
|
package/browser_control.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
162
|
-
"""
|
|
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
|
|
197
|
+
return []
|
|
172
198
|
command = entry.get("command") if isinstance(entry, dict) else None
|
|
173
|
-
|
|
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
|
-
|
|
231
|
+
executables = registered_executables(client)
|
|
183
232
|
ours = status().get(client, {}).get("state") in ("registered", "undenied")
|
|
184
|
-
if ours and
|
|
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)
|
|
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
|
|
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:
|