@allansantos-dev/smart-tool 0.9.27 → 0.9.28

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.28 - beta
4
+
5
+ - Node, npx and npm are looked up when they are needed, never remembered. A process keeps the PATH it was born with:
6
+ the tray started at logon never saw a Node installed, moved or removed afterwards, so the automatic update
7
+ (`npx --yes ... install`, run from the tray) stopped working once Node changed place, and everything the update then
8
+ started (installer, restarted tray) inherited the same stale PATH. New `node_locator.py`: the process PATH, then the
9
+ machine and user PATH stored in the registry, then the usual install folders (Program Files, nvm, Volta, fnm, Scoop,
10
+ Chocolatey); the Node inside Smart Tool (`web_adapters/node/.node-runtime`) is used only when the machine has none.
11
+ - The updater launches the install with that fresh PATH and the folder of the `npx` it found first in it.
12
+ - The web runtime installer takes the machine's Node from the PATH first (it preferred the one inside Smart Tool).
13
+ - Search adapters and the code analyzers resolve Node at each launch, so a Node changed while the daemon runs is
14
+ followed without a restart.
15
+ - The Playwright MCP registration writes the absolute Node path into the client's configuration; the daemon now
16
+ registers it again at start when that executable no longer exists (`browser_control.heal`).
17
+ - Tests: `tests/test_node_locator.py` (Node in an unusual folder, only in the registry PATH, only in a known folder,
18
+ nothing found, the updater without npx in the tray PATH, a registration whose Node is gone).
19
+
3
20
  ## 0.9.27 - beta
4
21
 
5
22
  - No folder is read before it is classified, and a tool's hidden folder (`.crowd`, `.tokenguard`, `.vscode`, any name
package/README.md CHANGED
@@ -15,7 +15,7 @@ registered as the `playwright` server where this machine lets a browser be drive
15
15
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
16
16
  are cheaper, or just tells the agent what Smart Tool would do.
17
17
 
18
- **Status:** 0.9.27 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
18
+ **Status:** 0.9.28 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
19
 
20
20
  ## How it works
21
21
 
@@ -26,6 +26,7 @@ import tomllib
26
26
  import atomic_io
27
27
  import endpoint_sync
28
28
  import install_runtime
29
+ import node_locator
29
30
  import paths
30
31
 
31
32
  SERVER = "playwright"
@@ -157,6 +158,34 @@ def registered():
157
158
  return found
158
159
 
159
160
 
161
+ def registered_command(client):
162
+ """Executable the client's `playwright` server is registered with, or None when it is not registered."""
163
+ try:
164
+ if client == "claude":
165
+ with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
166
+ entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
167
+ else:
168
+ with open(_codex_config(), encoding="utf-8") as stream:
169
+ entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
170
+ except (OSError, ValueError, AttributeError):
171
+ return None
172
+ command = entry.get("command") if isinstance(entry, dict) else None
173
+ return command if isinstance(command, str) else None
174
+
175
+
176
+ def heal(log=None):
177
+ """Registers again, through ensure, each registration of ours whose executable no longer exists: the Node path is
178
+ written into the client's configuration, which a moved or reinstalled Node leaves pointing nowhere. Returns
179
+ {client: ensure's outcome} for the clients refreshed."""
180
+ refreshed = {}
181
+ for client in ("claude", "codex"):
182
+ command = registered_command(client)
183
+ ours = status().get(client, {}).get("state") in ("registered", "undenied")
184
+ if ours and command and not node_locator.exists(command):
185
+ refreshed[client] = ensure(client, log)
186
+ return refreshed
187
+
188
+
160
189
  def _codex_config():
161
190
  """Codex keeps config.toml in CODEX_HOME when set, else ~/.codex."""
162
191
  return os.path.join(endpoint_sync.codex_dir(), "config.toml")
package/code_graph.py CHANGED
@@ -242,7 +242,7 @@ def _run_analyzer(script, payload, failure):
242
242
  needed more than the former fixed 512 MB). The run is killed after `ANALYZER_TIMEOUT_S` seconds (a context
243
243
  variable: the edit hook lowers it so a slow file cannot hold its worker)."""
244
244
  for heap in NODE_HEAP_MB:
245
- process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
245
+ process=subprocess.run([web_search_adapters.executable_path(web_search_adapters.NODE_EXECUTABLE),f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
246
246
  input=payload,capture_output=True,text=True,encoding='utf-8',timeout=ANALYZER_TIMEOUT_S.get(),
247
247
  creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
248
248
  if process.returncode==0:
@@ -21,6 +21,8 @@ import venv
21
21
  import zipfile
22
22
  from pathlib import Path
23
23
 
24
+ import node_locator
25
+
24
26
 
25
27
  ROOT = Path(__file__).resolve().parent
26
28
  NODE_DIR = ROOT / "web_adapters" / "node"
@@ -32,7 +34,7 @@ JAVA_PARSER = NODE_DIR / 'node_modules' / 'java-parser' / 'src' / 'index.js'
32
34
  ANGULAR_PARSER = NODE_DIR / 'node_modules' / '@angular' / 'compiler' / 'fesm2022' / 'compiler.mjs'
33
35
  # Browser control for agents (browser_control.py): Microsoft's Playwright MCP over the Chromium installed below.
34
36
  PLAYWRIGHT_MCP_CLI = NODE_DIR / "node_modules" / "@playwright" / "mcp" / "cli.js"
35
- NODE_RUNTIME = NODE_DIR / ".node-runtime"
37
+ NODE_RUNTIME = Path(node_locator.LOCAL_RUNTIME)
36
38
  # Moli renders the JavaScript pages web_fetch cannot read over HTTP; Chromium (Crawl4AI) stays as its fallback.
37
39
  # Measured on 2026-10-10 over 21 such pages: 1.6 s and 90 MB peak (median) against 4.2 s and 581 MB for Chromium.
38
40
  MOLI_VERSION = "1.1.15"
@@ -58,14 +60,16 @@ def _node_supported(node):
58
60
 
59
61
 
60
62
  def _node_commands():
63
+ """(node, npm) of the Node to use: the machine's, found through the PATH first, else the one inside Smart Tool;
64
+ (None, None) when neither is a supported version."""
65
+ node = node_locator.which("node")
66
+ npm = node_locator.which("npm.cmd") or node_locator.which("npm")
67
+ if node and npm and _node_supported(node):
68
+ return node, npm
61
69
  local_node, local_npm = NODE_RUNTIME / "node.exe", NODE_RUNTIME / "npm.cmd"
62
- if NODE_RUNTIME.exists():
63
- if local_node.is_file() and local_npm.is_file() and _node_supported(local_node):
64
- return str(local_node), str(local_npm)
65
- return None, None
66
- node = shutil.which("node")
67
- npm = shutil.which("npm.cmd") or shutil.which("npm")
68
- return (node, npm) if node and npm and _node_supported(node) else (None, None)
70
+ if local_node.is_file() and local_npm.is_file() and _node_supported(local_node):
71
+ return str(local_node), str(local_npm)
72
+ return None, None
69
73
 
70
74
 
71
75
  def _download_verified(url, expected_sha256, path):
@@ -169,10 +173,9 @@ def _install_moli():
169
173
  def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
170
174
  """Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
171
175
  after idle_s without any output, and stream shows the output live."""
172
- env = os.environ.copy()
176
+ node, _npm = _node_commands()
177
+ env = node_locator.environment(prefer=node)
173
178
  env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
174
- if NODE_RUNTIME.is_dir():
175
- env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
176
179
  flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
177
180
  for attempt in range(1, attempts + 1):
178
181
  print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
@@ -0,0 +1,103 @@
1
+ """Where Node.js (and npx/npm) is on this machine, looked up when it is needed and never remembered.
2
+
3
+ A process keeps the PATH it was born with: the tray started at logon never sees a Node installed, moved or removed
4
+ afterwards, and everything it launches (the updater, the installer, the daemon) inherits that stale PATH. The lookup is
5
+ therefore: the process PATH, then the PATH Windows stores for the machine and the user (what a new process would get),
6
+ then the usual install folders. The Node that Smart Tool downloads into web_adapters/node/.node-runtime is the last
7
+ resort of `node()`, used only when the machine has none.
8
+ """
9
+ import os
10
+ import shutil
11
+
12
+ LOCAL_RUNTIME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web_adapters", "node", ".node-runtime")
13
+ _MACHINE_ENVIRONMENT = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
14
+
15
+
16
+ def _registry_path():
17
+ """Entries of the machine and user PATH as stored in the registry, variables expanded."""
18
+ if os.name != "nt":
19
+ return []
20
+ import winreg
21
+ entries = []
22
+ for hive, subkey in ((winreg.HKEY_LOCAL_MACHINE, _MACHINE_ENVIRONMENT), (winreg.HKEY_CURRENT_USER, "Environment")):
23
+ try:
24
+ with winreg.OpenKey(hive, subkey) as key:
25
+ entries += os.path.expandvars(winreg.QueryValueEx(key, "Path")[0]).split(os.pathsep)
26
+ except FileNotFoundError:
27
+ continue
28
+ return entries
29
+
30
+
31
+ def _unique(entries):
32
+ seen, kept = set(), []
33
+ for entry in entries:
34
+ entry = entry.strip().strip('"')
35
+ if entry and os.path.normcase(entry) not in seen:
36
+ seen.add(os.path.normcase(entry))
37
+ kept.append(entry)
38
+ return kept
39
+
40
+
41
+ def search_path():
42
+ """The process PATH followed by the registry PATH entries it does not have."""
43
+ return _unique(os.environ.get("PATH", "").split(os.pathsep) + _registry_path())
44
+
45
+
46
+ def known_folders():
47
+ """Where Node installers and version managers put node.exe, for a machine whose PATH does not list it."""
48
+ program_files = [os.environ.get(name) for name in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)")]
49
+ local = os.environ.get("LOCALAPPDATA")
50
+ home = os.path.expanduser("~")
51
+ candidates = [os.path.join(base, "nodejs") for base in program_files if base]
52
+ candidates += [os.environ.get("NVM_SYMLINK"), os.environ.get("FNM_MULTISHELL_PATH"),
53
+ os.path.join(os.environ["VOLTA_HOME"], "bin") if os.environ.get("VOLTA_HOME") else None,
54
+ os.path.join(local, "Programs", "nodejs") if local else None,
55
+ os.path.join(home, "scoop", "apps", "nodejs", "current"),
56
+ os.path.join(home, "scoop", "apps", "nodejs-lts", "current"),
57
+ os.path.join(os.environ["ChocolateyInstall"], "bin") if os.environ.get("ChocolateyInstall") else None]
58
+ return _unique(path for path in candidates if path)
59
+
60
+
61
+ _FOUND = {}
62
+
63
+
64
+ def which(name):
65
+ """Full path of an executable (`node`, `npx.cmd`...): the PATH first, the known install folders only when the PATH
66
+ does not have it; None when neither does. A path found is remembered per PATH value and used again only while the
67
+ file is still there, so a lookup costs a stat, not a scan of every folder, and a moved Node is searched again."""
68
+ key = (name, os.environ.get("PATH", ""))
69
+ found = _FOUND.get(key)
70
+ if found and os.path.isfile(found):
71
+ return found
72
+ found = (shutil.which(name, path=os.pathsep.join(search_path()))
73
+ or shutil.which(name, path=os.pathsep.join(known_folders())))
74
+ if found:
75
+ _FOUND[key] = found
76
+ return found
77
+
78
+
79
+ def local_node():
80
+ """The Node downloaded into Smart Tool, or None."""
81
+ path = os.path.join(LOCAL_RUNTIME, "node.exe")
82
+ return path if os.path.isfile(path) else None
83
+
84
+
85
+ def node():
86
+ """node.exe of the machine (PATH first), else the Node inside Smart Tool, else None."""
87
+ return which("node") or local_node()
88
+
89
+
90
+ def environment(prefer=None):
91
+ """A copy of the environment with the current PATH (see search_path), the folder of `prefer` (an executable)
92
+ first: what a process launched now should inherit."""
93
+ env = {key: value for key, value in os.environ.items() if key.upper() != "PATH"}
94
+ entries = search_path()
95
+ if prefer:
96
+ entries.insert(0, os.path.dirname(prefer))
97
+ env["PATH"] = os.pathsep.join(_unique(entries))
98
+ return env
99
+
100
+
101
+ def exists(command):
102
+ """Whether a registered executable (a full path, or a bare name resolved through the PATH) is still there."""
103
+ return os.path.isfile(command) or bool(which(command))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.27",
3
+ "version": "0.9.28",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
@@ -40,6 +40,7 @@ import capabilities
40
40
  import client_hooks
41
41
  import install_runtime
42
42
  import integration_metrics
43
+ import node_locator
43
44
  import config
44
45
  import freshness
45
46
  import model_client
@@ -2078,9 +2079,9 @@ def _run_search_script(executable, script_path, args, timeout_s):
2078
2079
  if _IS_WINDOWS else {"start_new_session": True}
2079
2080
  )
2080
2081
  proc = subprocess.Popen(
2081
- [executable, script_path, *args],
2082
+ [web_search_adapters.executable_path(executable), script_path, *args],
2082
2083
  stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
2083
- env={**os.environ, "SMART_TOOL_USER_AGENT": version.user_agent(config.load_config().get("contact"))},
2084
+ env={**node_locator.environment(), "SMART_TOOL_USER_AGENT": version.user_agent(config.load_config().get("contact"))},
2084
2085
  **popen_kwargs,
2085
2086
  )
2086
2087
  try:
@@ -4183,6 +4184,14 @@ def _probe_capabilities():
4183
4184
  _log(f"capabilities probe failed: {type(exc).__name__}: {exc}")
4184
4185
 
4185
4186
 
4187
+ def _heal_browser_control():
4188
+ """Re-registers the Playwright MCP server when the Node it was registered with is gone (Node moved or reinstalled)."""
4189
+ try:
4190
+ browser_control.heal(_log)
4191
+ except Exception as exc:
4192
+ _log(f"browser control not refreshed: {type(exc).__name__}: {exc}")
4193
+
4194
+
4186
4195
  def main():
4187
4196
  global _PROJECT_MONITOR
4188
4197
  # O bind vem antes de qualquer I/O de rede: enquanto a porta não abre, o
@@ -4206,6 +4215,7 @@ def main():
4206
4215
  except (OSError, ValueError) as exc:
4207
4216
  _log(f"clients not pointed to port {_BOUND_PORT}: {type(exc).__name__}: {exc}")
4208
4217
  atexit.register(BROWSER_SERVICE.close)
4218
+ threading.Thread(target=_heal_browser_control, daemon=True).start()
4209
4219
  try:
4210
4220
  recovered = project_store.recover_interrupted()
4211
4221
  project_store.import_known_legacy(indexer.INDEX_DIR)
package/update_check.py CHANGED
@@ -13,6 +13,7 @@ import urllib.request
13
13
 
14
14
  import atomic_io
15
15
  import config
16
+ import node_locator
16
17
  import paths
17
18
  import version
18
19
 
@@ -136,7 +137,9 @@ def notices(now=None):
136
137
 
137
138
  def run_update(latest, automatic):
138
139
  """Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
139
- the tray). The console closes on success and stays open with the error on failure."""
140
+ the tray). The console closes on success and stays open with the error on failure. npx is looked up now, not in the
141
+ PATH the tray was born with: a Node installed or moved since the last logon still runs the update, and the folder
142
+ found is put first in the PATH the installer (and the tray it restarts) inherits."""
140
143
  if automatic:
141
144
  state = _state()
142
145
  state.update(attempted=latest, at=time.time())
@@ -144,4 +147,4 @@ def run_update(latest, automatic):
144
147
  flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
145
148
  command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
146
149
  subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
147
- cwd=os.path.expanduser("~"))
150
+ cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=node_locator.which("npx.cmd")))
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.27"
4
+ VERSION = "0.9.28"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
@@ -31,11 +31,14 @@ Bing entra pelo tier yahoo.
31
31
  import os
32
32
  import sys
33
33
 
34
+ import node_locator
35
+
34
36
  PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
35
37
  NODE_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "node")
36
38
  BROWSER_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "browser")
37
- LOCAL_NODE_EXECUTABLE = os.path.join(NODE_ADAPTER_DIR, ".node-runtime", "node.exe")
38
- NODE_EXECUTABLE = LOCAL_NODE_EXECUTABLE if os.path.isfile(LOCAL_NODE_EXECUTABLE) else "node"
39
+ # Adapters name Node, not a path: `executable_path` finds it when the adapter is launched (node_locator), so a Node
40
+ # installed, moved or removed while the daemon runs is followed without a restart.
41
+ NODE_EXECUTABLE = "node"
39
42
  CAMOUFOX_VENV_PYTHON = os.path.join(BROWSER_ADAPTER_DIR, ".venv", "Scripts", "python.exe")
40
43
  BROWSER_SERVICE_SCRIPT = os.path.join(BROWSER_ADAPTER_DIR, "browser_service.py")
41
44
  DDG_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "ddg-search.mjs")
@@ -44,6 +47,13 @@ WIKIPEDIA_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "wikipedia-search.mjs")
44
47
  EXTERNAL_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_external.py")
45
48
  DDGS_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_ddgs.py")
46
49
 
50
+
51
+ def executable_path(executable):
52
+ """The program to launch for an adapter's `executable`: Node is looked up now (PATH first, then the Node inside
53
+ Smart Tool); any other executable is returned unchanged. A Node that is nowhere stays `node`, so the launch fails
54
+ with the system's own "not found"."""
55
+ return (node_locator.node() or NODE_EXECUTABLE) if executable == NODE_EXECUTABLE else executable
56
+
47
57
  FETCH_TIMEOUT_S = 15
48
58
  OPEN_WEBSEARCH_TIMEOUT_S = 30
49
59
  WIKIPEDIA_TIMEOUT_S = 15