@allansantos-dev/smart-tool 0.9.4 → 0.9.6

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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.6 - beta
4
+
5
+ Reported by the claude-code-boss session validating 0.9.5.
6
+
7
+ - `project_manage action=duplicates` reads the files the index does not reflect yet (working tree, files changed on
8
+ disk, commits after the last indexing) and says how many it read: it kept reporting a function removed in a commit
9
+ made after the last indexing, with no sign that the index was behind.
10
+ - In `graph` impact results, a test or caller that is an anonymous callback registered with `test('title', fn)`,
11
+ `it()` or `describe()` shows its title (`test "embedder (Q47): ..."`) instead of `callback`.
12
+ - `graph` with `symbol` accepts a partial path before `::` (`brain-embedder.js::loadConfig`), matching the files whose
13
+ path ends with it; it needed the full path from the project root.
14
+
15
+ Automatic updates.
16
+
17
+ - When the tray starts (at logon, before agent sessions use the daemon) and npm has a newer version, it runs the
18
+ install command in a visible console that closes on success and stays open on failure. Each version is installed
19
+ automatically once: a failed update restores the previous version and restarts the tray, which then only offers it.
20
+ - The tray menu has "Update to X" (or "Check for updates", which says when the installed version is the latest) and an
21
+ "Update automatically" switch (`auto_update`, on by default). Later daily checks only notify.
22
+
23
+ Agents that ignored the hook's redirect: only 44 of 368 blocked calls (12%) were followed by a `smart_search` within
24
+ 30 s; the rest rewrote the search in Bash, node or python.
25
+
26
+ - The block message names the exact tool and arguments (`mcp__smart-tool__smart_search` with the project root filled
27
+ in, `web_fetch` with the URL), says it is a routing rule and not a failure, that redoing the search through Bash,
28
+ python, node or PowerShell bypasses it, and, in Claude Code, how to load the tool with ToolSearch.
29
+ - The MCP server sends instructions on what to do when a call is blocked, and `smart_search`, `web_fetch`,
30
+ `web_search` and their `_result` tools load upfront (`anthropic/alwaysLoad`): Claude Code defers MCP tools, so a
31
+ blocked agent often did not have the replacement in its tool list. In real `claude -p` sessions, an agent asked to
32
+ read a page went straight to `web_fetch`, and one asked to run a broad `grep -rn` switched to `smart_search` after
33
+ the block.
34
+ - `cd dir; grep -n x file.py` was measured as a search over the whole tree (the `;` stuck to the directory name hid
35
+ the file): 45 of the 92 logged Bash redirects with `cd dir;` were reads of one file. A command with a recursive
36
+ search after a file read (`sed -n 1,9p a.py; grep -rn x src`) is still measured by the directory it searches.
37
+ - A subagent whose tool list has no Smart Tool tool (claude-code-guide, statusline-setup, or an agent in
38
+ `.claude/agents` with a `tools` list without it) is not redirected: it had nothing to switch to and gave up.
39
+ - Bash commands that change files, the repository or dependencies (`git checkout`, `rm`, `sed -i`, `writeFileSync`,
40
+ `npm run`, ...) are never redirected and skip the router model: 12 of 283 Bash redirects were such commands.
41
+
42
+ ## 0.9.5 - beta
43
+
44
+ Reported by the claude-code-boss session on its first day using 0.9.4.
45
+
46
+ - `affected_tests` answered "no tests" for a module covered by a 1.4 MB test file (1,427 cases): the file was over the
47
+ index size limit and the working-tree reading skipped files over 1 MB, so it fell out of the import graph silently.
48
+ Files up to 5 MB are now analyzed for the graph, and any tracked test file still outside it is selected ("may cover
49
+ the change") instead of disappearing.
50
+ - Tests that run a script by path (`spawnSync('node', [path.join(SCRIPTS, 'hook.js')])`, `subprocess.run(["python",
51
+ "tools/x.py"])`) now depend on that script: a file name in a string literal of a test, resolved against the test's
52
+ folder, the folders above it and the project root, links them.
53
+ - A test is left out of the list only when it has no test cases (`conftest.py`, fixtures, helpers). The former rule
54
+ ("imported by another test") dropped real Java tests that share test types: on gson it caught 19 of 28 injected
55
+ faults; now 28 of 28. JUnit 3 suites (`@RunWith(AllTests.class)`, `static Test suite()`) count as tests.
56
+ - Test files named in `package.json` scripts (`"test": "node scripts/test-units.js"`) get a `node <file>` command.
57
+ - An anonymous callback among the touched functions is named after the function that contains it.
58
+ - `project_manage register` and `status` return the project with its last jobs summarized and without the scope's
59
+ folder structure and profile groups (`scope_summary` keeps include/exclude): they returned the full result of every
60
+ past search (108-128 thousand characters on one project, now 3.3 thousand). The projects screen keeps the full
61
+ payload.
62
+ - A project is no longer marked `degraded` because the per-chunk function analysis was still being prepared: that note
63
+ is shown with the search results but is not a degradation.
64
+
3
65
  ## 0.9.4 - beta
4
66
 
5
67
  Found by using 0.9.3 on its own repository right after installing it.
package/README.md CHANGED
@@ -29,7 +29,9 @@ are cheaper, or just tells the agent what Smart Tool would do.
29
29
  npx @allansantos-dev/smart-tool@latest install
30
30
  ```
31
31
 
32
- Run the same command to update; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
32
+ Updates install themselves: when the tray starts (at logon) and npm has a newer version, it runs this same command in
33
+ a visible console, once per version; the tray menu has "Update to X" (or "Check for updates") on demand and an
34
+ "Update automatically" switch. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
33
35
  verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` or the registry, then
34
36
  copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
35
37
  installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
package/affected_tests.py CHANGED
@@ -1,12 +1,13 @@
1
1
  """Tests to run for the current changes: git diff against a base (default HEAD, so staged, unstaged and untracked
2
2
  files), then every test file that imports a changed file directly or through other files, plus changed tests, ordered
3
3
  so the most likely failures come first (tests calling a touched function, test file named after the function or the
4
- module, import distance). Test support files (conftest.py, helpers other tests import) carry the selection but are
5
- not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
4
+ module, import distance). Test support files (conftest.py, fixtures and helpers without test cases) carry the
5
+ selection but are not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
6
6
  JavaScript, TypeScript and Java projects static calls alone found 2 to 37% of the failing test files."""
7
7
  import ast
8
8
  import json
9
9
  import os
10
+ import posixpath
10
11
  import re
11
12
  import subprocess
12
13
  from collections import defaultdict, deque
@@ -30,6 +31,8 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
30
31
  "suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
31
32
  "unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
32
33
  "files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
34
+ TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
35
+ _PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
33
36
  _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
34
37
 
35
38
 
@@ -122,6 +125,10 @@ def _commands(root, tests):
122
125
  runner = next((cmd for key, cmd in (("vitest", "npx vitest run"), ("jest", "npx jest"),
123
126
  ("mocha", "npx mocha"), ("node --test", "node --test"))
124
127
  if key in tools), None)
128
+ local = os.path.relpath(path, folder or ".").replace(os.sep, "/")
129
+ scripts = " ".join(str(v) for v in (package.get("scripts") or {}).values())
130
+ if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
131
+ runner = "node-script"
125
132
  groups[(folder or "", runner)].append(path)
126
133
  elif lower.endswith(".java"):
127
134
  folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
@@ -138,6 +145,9 @@ def _commands(root, tests):
138
145
  if runner == "maven":
139
146
  names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
140
147
  line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
148
+ elif runner == "node-script":
149
+ commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
150
+ continue
141
151
  elif runner == "unittest-package":
142
152
  line = "python -m unittest " + " ".join(sorted(f[:-3].replace("/", ".") for f in files))
143
153
  elif runner == "unittest-folder":
@@ -194,6 +204,75 @@ def _fixture_users(root, test_files):
194
204
  return users
195
205
 
196
206
 
207
+ def _path_literal_users(root, test_files, known):
208
+ """Code files a test names in a string literal ('graph-guard.js', "scripts/run.py"), resolved against the test's
209
+ folder, the folders above it and the project root: tests that run scripts as subprocesses or load them by path
210
+ (spawnSync('node', [path.join(SCRIPTS, 'hook.js')])) depend on them without importing them."""
211
+ users = defaultdict(set)
212
+ for test in test_files:
213
+ if not test.lower().endswith(TEST_SOURCES):
214
+ continue
215
+ source = _read(os.path.join(root, test))
216
+ folders, folder = [], os.path.dirname(test)
217
+ while True:
218
+ folders.append(folder)
219
+ if not folder:
220
+ break
221
+ folder = os.path.dirname(folder)
222
+ for literal in set(_PATH_LITERAL.findall(source)):
223
+ for base in folders:
224
+ candidate = posixpath.normpath(posixpath.join(base, literal.removeprefix("./")))
225
+ if candidate in known and candidate != test:
226
+ users[candidate].add(test)
227
+ break
228
+ return users
229
+
230
+
231
+ _TEST_CASES = {
232
+ ".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
233
+ ".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
234
+ r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
235
+ "script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
236
+ }
237
+
238
+
239
+ def _support(root, path):
240
+ """Test-kind file without test cases (conftest.py, fixtures, helpers): it carries the selection but is not run.
241
+ A test referenced by another test (shared Java test types) is still a test."""
242
+ if os.path.basename(path) == "conftest.py":
243
+ return True
244
+ ext = os.path.splitext(path)[1].lower()
245
+ pattern = _TEST_CASES.get(ext) or (_TEST_CASES["script"] if ext in TEST_SOURCES else None)
246
+ source = _read(os.path.join(root, path))
247
+ return bool(pattern and source and not pattern.search(source))
248
+
249
+
250
+ def _tracked_tests_outside_graph(root, parsed, profile):
251
+ """Code test files git tracks that the graph could not analyze (over the size limits, unreadable): their imports
252
+ are unknown, so they are selected instead of silently missing (a 1.4 MB test file covering 1,427 cases did)."""
253
+ listed = subprocess.run(["git", "ls-files", "-z"], cwd=root, capture_output=True, timeout=30)
254
+ if listed.returncode:
255
+ return []
256
+ tests = []
257
+ for rel in listed.stdout.decode("utf-8", "replace").split("\0"):
258
+ if rel and rel not in parsed and rel.lower().endswith(TEST_SOURCES) and os.path.basename(rel) != "conftest.py" \
259
+ and index_profile.kind(rel, profile) == "test" and os.path.isfile(os.path.join(root, rel)):
260
+ tests.append(rel)
261
+ return tests
262
+
263
+
264
+ def _label(symbol, by_id):
265
+ """path::name of a touched function; an anonymous callback is named after the function that contains it."""
266
+ short = symbol["name"].split("(")[0].split(".")[-1]
267
+ if short in ("callback", "anonymous"):
268
+ parent = by_id.get(symbol.get("parent"))
269
+ while parent and parent["name"].split("(")[0].split(".")[-1] in ("callback", "anonymous"):
270
+ parent = by_id.get(parent.get("parent"))
271
+ if parent:
272
+ return f"{symbol['path']}::{parent['name']} (callback at line {symbol['start_line']})"
273
+ return f"{symbol['path']}::{symbol['name']}"
274
+
275
+
197
276
  def affected(root, base="HEAD", limit=30, view_id=None):
198
277
  """Test files to run for the changes since base, most likely failures first, with the command to run them."""
199
278
  if not isinstance(base, str) or not re.fullmatch(r"[\w./@^~{}-]{1,200}", base) or base.startswith("-"):
@@ -217,6 +296,8 @@ def affected(root, base="HEAD", limit=30, view_id=None):
217
296
  callers[call["target"]].add(call["source"])
218
297
  for conftest, users in _fixture_users(root, test_files).items():
219
298
  importers[conftest] |= users
299
+ for target, users in _path_literal_users(root, test_files, {f["path"] for f in data.get("files") or []}).items():
300
+ importers[target] |= users
220
301
  by_id = {s["id"]: s for s in data.get("symbols") or []}
221
302
  run_all, not_analyzed, changed_code, changed_tests, conftests = [], [], [], [], []
222
303
  for path, change in sorted(changes.items()):
@@ -260,9 +341,11 @@ def affected(root, base="HEAD", limit=30, view_id=None):
260
341
  selected.setdefault(test, f"{conftest} changed")
261
342
  for test in set(import_depth) | set(call_hops):
262
343
  selected.setdefault(test, None)
263
- support = {p for p in test_files if os.path.basename(p) == "conftest.py"
264
- or any(source in test_files for source in importers.get(p, ()))}
265
- selected = {test: reason for test, reason in selected.items() if test not in support}
344
+ selected = {test: reason for test, reason in selected.items() if not _support(root, test)}
345
+ if changed_code or conftests:
346
+ for test in _tracked_tests_outside_graph(root, parsed, profile):
347
+ selected.setdefault(test, "test file outside the import graph (too large or not analyzed): may cover "
348
+ "the change")
266
349
 
267
350
  def why(test):
268
351
  if selected[test]:
@@ -283,7 +366,7 @@ def affected(root, base="HEAD", limit=30, view_id=None):
283
366
  "base": base,
284
367
  "changed": {"code": changed_code[:limit], "tests": changed_tests[:limit],
285
368
  "other": sorted(p for p in changes if p not in changed_code and p not in changed_tests)[:limit]},
286
- "touched_functions": [f"{s['path']}::{s['name']}" for s in touched][:limit],
369
+ "touched_functions": [_label(s, by_id) for s in touched][:limit],
287
370
  "tests": [{"path": t, "why": why(t)} for t in ordered[:limit]],
288
371
  "counts": {"changed_files": len(changes), "tests": len(ordered), "test_files": len(test_files)},
289
372
  "run_all": bool(run_all),
package/code_graph.py CHANGED
@@ -23,7 +23,7 @@ import document_text
23
23
 
24
24
  VERSION = 2
25
25
  SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
26
- MAX_OVERLAY_BYTES = 1024 * 1024
26
+ MAX_OVERLAY_BYTES = 5 * 1024 * 1024
27
27
  MAX_FILES = 3000
28
28
  MAX_TEXT = 24 * 1024 * 1024
29
29
  MAX_SYMBOLS = 8000
package/code_impact.py CHANGED
@@ -2,6 +2,7 @@
2
2
  which tests reach it through static calls and which files import its module, each with the first line of its
3
3
  docstring. Answers the question an agent asks before an edit without a chain of searches and file reads."""
4
4
  import os
5
+ import re
5
6
  from collections import defaultdict
6
7
 
7
8
  import code_graph
@@ -17,7 +18,7 @@ NOTE = ("Static analysis of the indexed snapshot plus the files changed in the w
17
18
  def _matches(symbols, name):
18
19
  path, _sep, wanted = name.strip().rpartition("::")
19
20
  path = path.replace("\\", "/").removeprefix("./")
20
- pool = [s for s in symbols if s["path"] == path] if path else symbols
21
+ pool = [s for s in symbols if s["path"] == path or s["path"].endswith("/" + path)] if path else symbols
21
22
  exact = [s for s in pool if s["id"] == wanted or s["name"] == wanted]
22
23
  return exact or [s for s in pool if s["name"].split(".")[-1] == wanted]
23
24
 
@@ -49,6 +50,31 @@ def summaries(root, symbols, wanted_ids):
49
50
  if s["id"] in wanted_ids and (s["path"], s["start_line"]) in docs}
50
51
 
51
52
 
53
+ _TEST_TITLE = re.compile(r"""\b(test|it|describe|suite)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\2""")
54
+
55
+
56
+ def _name_test_callbacks(root, by_id, sites):
57
+ """Anonymous callbacks registered with test('title', fn), it(), describe() are named after their title, so a test
58
+ reads 'test "loads the config"' instead of 'callback'."""
59
+ lines_of = {}
60
+ for site in sites:
61
+ symbol = by_id.get(site.get("id")) or {}
62
+ if symbol.get("name", "").split(".")[-1] not in ("callback", "anonymous"):
63
+ continue
64
+ path = symbol["path"]
65
+ if path not in lines_of:
66
+ try:
67
+ with open(os.path.join(root, path), encoding="utf-8") as stream:
68
+ lines_of[path] = stream.read().splitlines()
69
+ except (OSError, UnicodeDecodeError):
70
+ lines_of[path] = []
71
+ start = symbol["start_line"]
72
+ text = " ".join(lines_of[path][max(start - 2, 0):start])
73
+ match = _TEST_TITLE.search(text)
74
+ if match:
75
+ site["function"] = f'{match.group(1)} "{match.group(3)[:80]}"'
76
+
77
+
52
78
  def impact(root, symbol, view_id=None, depth=3, limit=30):
53
79
  if not isinstance(symbol, str) or not symbol.strip():
54
80
  raise ValueError("Pass symbol: a function, method (Class.method) or class name.")
@@ -92,6 +118,7 @@ def impact(root, symbol, view_id=None, depth=3, limit=30):
92
118
  importers = sorted({d["source"] for d in data["dependencies"] if d.get("target") in files and d["source"] not in files})
93
119
  ordered_tests = sorted(tests.values(), key=lambda t: (t["hops"], t["path"], t["line"]))
94
120
  shown = direct[:limit] + outgoing[:limit] + ordered_tests[:limit]
121
+ _name_test_callbacks(root, by_id, shown)
95
122
  docs = summaries(root, data["symbols"], targets | {site["id"] for site in shown})
96
123
  for site in shown:
97
124
  doc = docs.get(site.pop("id"))
package/config.py CHANGED
@@ -33,6 +33,7 @@ DEFAULT_CONFIG = {
33
33
  "hook_mode": "redirect",
34
34
  "doc_mode": "remind",
35
35
  "duplicate_mode": "warn",
36
+ "auto_update": True,
36
37
  "contact": "",
37
38
  "model_adapter": "",
38
39
  "model_adapter_options": {},
@@ -66,6 +67,14 @@ def duplicate_mode(cfg=None):
66
67
  return value
67
68
 
68
69
 
70
+ def auto_update(cfg=None):
71
+ """Whether the tray installs a newer release by itself when it starts (once per version)."""
72
+ value = (cfg if cfg is not None else load_config()).get("auto_update", DEFAULT_CONFIG["auto_update"])
73
+ if not isinstance(value, bool):
74
+ raise ValueError(f"Invalid auto_update ({value!r}) in {CONFIG_PATH}: use true or false.")
75
+ return value
76
+
77
+
69
78
  def hook_mode(cfg=None):
70
79
  value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
71
80
  if value not in HOOK_MODES:
package/duplicates.py CHANGED
@@ -199,13 +199,16 @@ def _entry(path, name, start, end, file_lines, ext):
199
199
  "hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), **_features(body, ext, short)}
200
200
 
201
201
 
202
- def _working_tree_functions(root, changes, profile):
203
- """Production functions of the files changed in the working tree, read from disk, so a copy of a function written
204
- earlier in the same session is caught before the index catches up."""
202
+ def _working_tree_functions(root, changes, profile, include_tests=False):
203
+ """Functions of the files the index does not reflect yet, read from disk (production code, plus tests when
204
+ include_tests), with the same identity as indexed ones, so a copy written earlier in the session is caught and
205
+ a function removed since the last indexing is not reported."""
205
206
  found = []
207
+ kinds = ("code", "test") if include_tests else ("code",)
206
208
  for rel, status in changes.items():
207
209
  ext = rel.rsplit(".", 1)[-1].lower() if "." in rel else ""
208
- if status == "deleted" or ext not in _LANG or index_profile.kind(rel, profile) != "code":
210
+ kind = index_profile.kind(rel, profile)
211
+ if status == "deleted" or ext not in _LANG or kind not in kinds:
209
212
  continue
210
213
  try:
211
214
  with open(os.path.join(root, rel), encoding="utf-8") as stream:
@@ -215,12 +218,18 @@ def _working_tree_functions(root, changes, profile):
215
218
  if _GENERATED.search(source[:4000]):
216
219
  continue
217
220
  lines = source.splitlines()
218
- for function in doc_check.functions(rel, source):
219
- if function["end"] - function["start"] + 1 < MIN_LINES:
221
+ functions = doc_check.functions(rel, source)
222
+ occurrences = {}
223
+ for function in functions:
224
+ if function["end"] - function["start"] + 1 < MIN_LINES or any(
225
+ other is not function and other["start"] <= function["start"] and function["end"] <= other["end"]
226
+ and (other["start"], other["end"]) != (function["start"], function["end"]) for other in functions):
220
227
  continue
221
228
  entry = _entry(rel, function["name"], function["start"], function["end"], lines, ext)
222
229
  if entry:
223
- found.append({**entry, "kind": "code"})
230
+ occurrence = occurrences[entry["hash"]] = occurrences.get(entry["hash"], 0) + 1
231
+ found.append({**entry, "kind": kind, "fp": hashlib.sha1(
232
+ f"{rel}\0{entry['hash']}\0{occurrence}".encode("utf-8")).hexdigest()})
224
233
  return found
225
234
 
226
235
 
@@ -359,6 +368,13 @@ def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, i
359
368
  started = time.monotonic()
360
369
  dismissed = dismissed or {}
361
370
  functions, notes, view = _functions(root, include_tests)
371
+ if view.get("current"):
372
+ pending = code_graph.pending_changes(root, view["path"])
373
+ if pending:
374
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
375
+ functions = [f for f in functions if f["path"] not in pending] + _working_tree_functions(
376
+ root, pending, profile, include_tests)
377
+ notes.append(f"{len(pending)} file(s) changed since the last indexing were read from disk.")
362
378
  groups = {}
363
379
  for f in functions:
364
380
  groups.setdefault(f["hash"], []).append(f)
package/hook_decision.py CHANGED
@@ -42,6 +42,114 @@ def deny(reason):
42
42
  "permissionDecisionReason": reason}}
43
43
 
44
44
 
45
+ # Claude Code built-in subagents whose tool list has no MCP tool (claude-code-guide: Glob, Grep, Read, WebFetch,
46
+ # WebSearch; statusline-setup: Read, Edit).
47
+ BUILTIN_AGENTS_WITHOUT_MCP = frozenset({"claude-code-guide", "statusline-setup"})
48
+
49
+
50
+ def _frontmatter(path):
51
+ """`name` and `tools` of an agent definition (.md with YAML frontmatter); tools is None when the field is absent
52
+ (the agent inherits every tool), else the list of tool names (inline `[a, b]`, `a, b` or a `- a` block)."""
53
+ with open(path, encoding="utf-8") as stream:
54
+ lines = stream.read().splitlines()
55
+ if not lines or lines[0].strip() != "---":
56
+ return None, None
57
+ name, tools, block = None, None, False
58
+ for line in lines[1:]:
59
+ if line.strip() == "---":
60
+ break
61
+ if block and line.lstrip().startswith("- "):
62
+ tools.append(line.strip()[2:].strip().strip("'\""))
63
+ continue
64
+ block = False
65
+ key, _sep, value = line.partition(":")
66
+ value = value.strip()
67
+ if key == "name":
68
+ name = value.strip("'\"")
69
+ elif key == "tools":
70
+ if value:
71
+ tools = [item.strip().strip("'\"") for item in value.strip("[]").split(",") if item.strip()]
72
+ else:
73
+ tools, block = [], True
74
+ return name, tools
75
+
76
+
77
+ def agent_has_smart_tool(agent_type, cwd):
78
+ """Whether the subagent running the call can call the Smart Tool MCP tools: denying a native tool to an agent that
79
+ has no replacement only makes it give up. Agents defined in .claude/agents (user or project) are read; unknown
80
+ agents keep the redirect."""
81
+ if not agent_type:
82
+ return True
83
+ if agent_type in BUILTIN_AGENTS_WITHOUT_MCP:
84
+ return False
85
+ for folder in (os.path.join(cwd or "", ".claude", "agents"), os.path.join(endpoint_sync.claude_dir(), "agents")):
86
+ try:
87
+ names = sorted(os.listdir(folder))
88
+ except OSError:
89
+ continue
90
+ for entry in names:
91
+ if not entry.endswith(".md"):
92
+ continue
93
+ try:
94
+ name, tools = _frontmatter(os.path.join(folder, entry))
95
+ except (OSError, UnicodeDecodeError):
96
+ continue
97
+ if (name or entry.split(".")[0]) != agent_type:
98
+ continue
99
+ return tools is None or any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in tools)
100
+ return True
101
+
102
+
103
+ def _search_root(tool_input, cwd):
104
+ """project_root for smart_search: the registered project holding what the call searches (its path field, or the
105
+ first existing path of the command, after any `cd`), else that directory; the session directory when the call
106
+ names no path."""
107
+ raw = router._target_path(tool_input, cwd)
108
+ paths = [router._resolve_path(raw, cwd)] if raw else router._command_paths(tool_input.get("command"), cwd)
109
+ target = os.path.abspath(next((p for p in paths if p), None) or cwd or ".")
110
+ if os.path.isfile(target):
111
+ target = os.path.dirname(target)
112
+ key = os.path.normcase(target) + os.sep
113
+ roots = [p["root"] for p in project_store.all_projects()
114
+ if key.startswith(os.path.normcase(os.path.abspath(p["root"])).rstrip(os.sep) + os.sep)]
115
+ return max(roots, key=len) if roots else target
116
+
117
+
118
+ def _tool_name(client, tool):
119
+ return f"mcp__{SMART_TOOL_SERVER}__{tool}" if client == "claude" else f"the {tool} tool of the {SMART_TOOL_SERVER} MCP server"
120
+
121
+
122
+ def _load_hint(client, tool):
123
+ """Claude Code defers MCP tools behind tool search: a blocked agent may not have the schema loaded yet."""
124
+ if client != "claude":
125
+ return ""
126
+ return f' If it is not in your tool list yet, load it first with ToolSearch, query "select:mcp__{SMART_TOOL_SERVER}__{tool}".'
127
+
128
+
129
+ def redirect_message(client, tool_name, tool_input, reason, cwd):
130
+ """Deny text for a broad raw search: the exact tool and arguments to call, and that rewriting the same search in
131
+ Bash, python, node or PowerShell is the bypass the rule exists to stop."""
132
+ root = _search_root(tool_input, cwd).replace("\\", "/")
133
+ return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure ({tool_name}: {clean_reason(reason)}). "
134
+ f'Run this search with {_tool_name(client, "smart_search")}: project_root="{root}", query_identifiers = what '
135
+ f"you are looking for in identifier terms (plus query_comments when the project has two languages)."
136
+ f'{_load_hint(client, "smart_search")} Do not repeat the same search through Bash, python, node or '
137
+ f"PowerShell: that bypasses the rule. Reading one known file, or searching inside one known file, is never "
138
+ f"blocked.")
139
+
140
+
141
+ def web_message(client, tool_name, url, why):
142
+ """Deny text for native WebFetch/WebSearch: the Smart Tool call to make instead, with the URL filled in."""
143
+ if tool_name == "WebFetch":
144
+ call = f'{_tool_name(client, "web_fetch")} with url="{url}" and prompt = what you need from the page'
145
+ tool, fetchers = "web_fetch", "curl, wget, python or PowerShell"
146
+ else:
147
+ call = f"{_tool_name(client, 'web_search')} with query (and query_en in English)"
148
+ tool, fetchers = "web_search", "curl, a browser script or another search tool"
149
+ return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure. Call {call}: {why}"
150
+ f"{_load_hint(client, tool)} Do not fetch it through {fetchers}: that bypasses the rule.")
151
+
152
+
45
153
  def smart_tool_in_session(cwd):
46
154
  """Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
47
155
  with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
@@ -149,7 +257,8 @@ def _edit_review(payload, doc_mode, duplicate_mode):
149
257
 
150
258
  def decide(payload, client, web_route):
151
259
  """Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
152
- (warn | off), the other tools follow hook_mode (redirect | advise | off)."""
260
+ (warn | off), the other tools follow hook_mode (redirect | advise | off). A subagent whose tool list has no
261
+ Smart Tool MCP tool is never redirected: it has nothing to switch to."""
153
262
  cfg = config.load_config()
154
263
  if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
155
264
  doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
@@ -161,6 +270,9 @@ def decide(payload, client, web_route):
161
270
  return {}
162
271
  output = _route(payload, client, web_route, cfg)
163
272
  reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
273
+ if reason and not agent_has_smart_tool(payload.get("agent_type") if payload.get("agent_id") else None,
274
+ payload.get("cwd")):
275
+ return {}
164
276
  return advise(reason) if mode == "advise" and reason else output
165
277
 
166
278
 
@@ -178,8 +290,9 @@ def _route(payload, client, web_route, cfg):
178
290
  present = False
179
291
  if not present:
180
292
  return {}
181
- decision = web_route(tool_name, str(tool_input.get("url") or ""))
182
- return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
293
+ url = str(tool_input.get("url") or "")
294
+ decision = web_route(tool_name, url)
295
+ return deny(web_message(client, tool_name, url, clean_reason(decision.get("reason")))) if decision.get("redirect") else {}
183
296
  router.CLIENT.set(client)
184
297
  try:
185
298
  decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
@@ -190,5 +303,5 @@ def _route(payload, client, web_route, cfg):
190
303
  pass
191
304
  return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
192
305
  if decision == "redirect":
193
- return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
306
+ return deny(redirect_message(client, tool_name, tool_input, reason, cwd))
194
307
  return {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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",
package/router.py CHANGED
@@ -74,6 +74,7 @@ _HEAVY_DIR_NAMES = frozenset({
74
74
  # dos de cima superestimaria o custo e enviesaria a decisão pra `redirect`.
75
75
  _IGNORED_BY_DEFAULT_DIRS = frozenset({".git", "__pycache__", ".mypy_cache", ".pytest_cache"})
76
76
  _PATH_FIELDS = ("path", "file_path", "notebook_path")
77
+ _RECURSIVE_RE = re.compile(r"\b(?:grep|egrep|fgrep)\s+(?:-\w+\s+)*-\w*[rR]|\b(?:rg|find|fd|tree)\s|\bls\s+-\w*R|-Recurse\b")
77
78
 
78
79
 
79
80
  def _resolve_path(raw, cwd):
@@ -107,6 +108,9 @@ def _command_paths(command, cwd):
107
108
  previous, base = "", cwd
108
109
  for token in re.findall(r'"([^"]+)"|\'([^\']+)\'|(\S+)', command or ""):
109
110
  raw = next((t for t in token if t), "")
111
+ if token[2]:
112
+ # `cd dir; grep x file`: the separator sticks to the unquoted word before it.
113
+ raw = raw.rstrip(";")
110
114
  after_cd, previous = previous.lower() in ("cd", "pushd", "set-location", "sl"), raw
111
115
  if after_cd:
112
116
  # The directory of a `cd` is where the command runs (base for what follows), not what it searches.
@@ -167,6 +171,9 @@ def _target_facts(tool_name, tool_input, cwd):
167
171
  raw = _target_path(tool_input, cwd)
168
172
  if raw is None and tool_name == "Bash":
169
173
  candidates = _command_paths(tool_input.get("command"), cwd)
174
+ if candidates and _RECURSIVE_RE.search(tool_input.get("command") or ""):
175
+ # `sed -n 1,9p a.py; grep -rn x src`: the recursive part is the cost, not the file read before it.
176
+ candidates = [c for c in candidates if os.path.isdir(c)] or candidates
170
177
  if candidates:
171
178
  raw = candidates[0]
172
179
  scope_note = None
@@ -308,6 +315,22 @@ _READ_PRODUCER_RE = re.compile(
308
315
  re.IGNORECASE,
309
316
  )
310
317
  _READ_TOOLS = frozenset({"Read", "NotebookRead"})
318
+ # Commands that change files, the repository or dependencies are never redirected (smart_search cannot do the change).
319
+ # Verbs are matched outside quoted strings, so a grep pattern like "pip install" is not one; file writes inside an
320
+ # inline script (node -e, python -c) are matched anywhere.
321
+ _QUOTED_RE = re.compile(r"""'[^']*'|"(?:\\.|[^"\\])*\"""")
322
+ _MUTATING_VERB_RE = re.compile(
323
+ r"(?:^|[;&|(]|\s)(?:rm|mv|cp|mkdir|rmdir|touch|chmod|tee)\s"
324
+ r"|\bgit\s+(?:-C\s+\S+\s+)?(?:checkout|commit|add|push|pull|reset|stash|restore|rm|mv|merge|rebase|apply|tag|clean|"
325
+ r"clone|switch|cherry-pick)\b|\bsed\s+-i"
326
+ r"|\b(?:npm|pnpm|yarn|pip|uv)\s+(?:install|i|add|run|publish|ci|test)\b"
327
+ )
328
+ _FILE_WRITE_RE = re.compile(r"writeFileSync|writeFile\(|write_text\(|Set-Content|Out-File")
329
+
330
+
331
+ def _mutates(command):
332
+ """Whether a shell command changes files, the repository or dependencies."""
333
+ return bool(_FILE_WRITE_RE.search(command) or _MUTATING_VERB_RE.search(_QUOTED_RE.sub("''", command)))
311
334
 
312
335
  BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
313
336
  BREAKER_FAILURES = 3
@@ -324,7 +347,9 @@ def _mechanical_decision(tool_name, tool_input):
324
347
  `offset`/`limit` explícitos, onde só as linhas pedidas servem). Remedido em 2026-10-08
325
348
  sobre 2488 Bash que foram ao modelo (mediana 1,5 s, 7,8% redirecionados): ignorar os
326
349
  filtros depois de `|` (salvo quando o produtor lê código ou páginas) decide 439 deles
327
- sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos."""
350
+ sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos. Remedido em 2026-10-09 sobre 3294 Bash decididos
351
+ pelo modelo: comandos que alteram arquivos, o repositório ou dependências foram 12 dos 283 redirecionamentos (todos
352
+ errados: o agente contornava o bloqueio) e 566 liberações."""
328
353
  if tool_name in _READ_TOOLS:
329
354
  return "allow", "Reading a specific file is never replaced by semantic search."
330
355
  if tool_name == "Bash":
@@ -333,6 +358,8 @@ def _mechanical_decision(tool_name, tool_input):
333
358
  return "allow", "Bash without a readable command."
334
359
  if not _SEARCH_VERB_RE.search(command) and not _READ_PRODUCER_RE.search(command):
335
360
  return "allow", "The command neither searches nor reads source code."
361
+ if _mutates(command):
362
+ return "allow", "The command changes files, the repository or dependencies."
336
363
  return None
337
364
 
338
365
 
@@ -300,6 +300,20 @@ def _smart_search_job_payload(job_id):
300
300
  payload['view'] = job['view']
301
301
  return payload
302
302
 
303
+ # Claude Code defers MCP tools behind tool search; the tools the hook redirects to (and their job polling) load
304
+ # upfront, so a blocked agent already has the replacement in its tool list.
305
+ ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
306
+ SERVER_INSTRUCTIONS = (
307
+ "Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. The user installed "
308
+ "a PreToolUse hook that blocks broad raw searches (recursive grep/find/ls, wide Glob/Grep) and native "
309
+ "WebFetch/WebSearch. A call blocked by Smart Tool is a routing rule, not a failure: make the call the block "
310
+ "message names (smart_search with project_root and query_identifiers, web_fetch with url and prompt, web_search "
311
+ "with query). Never retry the same search or download through Bash, python, node, PowerShell, curl or wget: that "
312
+ "bypasses the rule the user set. Reading one known file, or searching inside one known file, is never blocked. "
313
+ "Before changing a function, project_manage action=graph with symbol lists its callers and tests; after editing, "
314
+ "project_manage action=affected_tests lists the tests to run."
315
+ )
316
+
303
317
  TOOLS = [
304
318
  {
305
319
  "name": "project_manage",
@@ -339,6 +353,7 @@ TOOLS = [
339
353
  },
340
354
  {
341
355
  "name": "smart_search",
356
+ "_meta": ALWAYS_LOAD,
342
357
  "description": (
343
358
  "Semantic search (embed+rerank) over the project's index. Use instead of "
344
359
  "raw grep/read when the search has no clear scope (e.g. a folder with node_modules). "
@@ -372,6 +387,7 @@ TOOLS = [
372
387
  },
373
388
  {
374
389
  "name": "smart_search_result",
390
+ "_meta": ALWAYS_LOAD,
375
391
  "description": (
376
392
  "Checks the progress and result of a background smart_search. "
377
393
  "Pass the job_id returned by smart_search; while pending, wait retry_after_s and check again."
@@ -386,6 +402,7 @@ TOOLS = [
386
402
  },
387
403
  {
388
404
  "name": "web_search",
405
+ "_meta": ALWAYS_LOAD,
389
406
  "description": (
390
407
  "Web search with curated results (title/url/snippet) - use instead of "
391
408
  "the native WebSearch: several sources in parallel, the query in two languages and a cache shared across sessions. Starts with free HTTP "
@@ -433,6 +450,7 @@ TOOLS = [
433
450
  },
434
451
  {
435
452
  "name": "web_fetch",
453
+ "_meta": ALWAYS_LOAD,
436
454
  "description": (
437
455
  "Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
438
456
  "the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
@@ -449,6 +467,7 @@ TOOLS = [
449
467
  },
450
468
  {
451
469
  "name": "web_search_result",
470
+ "_meta": ALWAYS_LOAD,
452
471
  "description": (
453
472
  "Checks the status/result of a deep research job "
454
473
  "(web_search with depth='deep' or depth='research')."
@@ -968,8 +987,10 @@ def _perform_smart_search_isolated(arguments, job_id):
968
987
  else ({}, None))
969
988
  except Exception as exc:
970
989
  symbol_index, symbol_note = None, f"Function analysis failed: {_project_error(exc)}"
990
+ notes = []
971
991
  if symbol_index is None and any(group != "doc" for group in groups):
972
- warnings.append(symbol_note or "Per-chunk functions are being prepared; they will show on the next search.")
992
+ (warnings if symbol_note else notes).append(
993
+ symbol_note or "Per-chunk functions are being prepared; they will show on the next search.")
973
994
  elif symbol_note or (symbol_index or {}).get("note"):
974
995
  warnings.append(symbol_note or symbol_index["note"])
975
996
  query_terms = _symbol_terms(" ".join(queries[group] for group in groups if group != "doc"))
@@ -1013,7 +1034,7 @@ def _perform_smart_search_isolated(arguments, job_id):
1013
1034
  warning = " ".join(warnings)
1014
1035
  if warning:
1015
1036
  _update_job(job_id, warning=warning)
1016
- return _grouped_yaml(index_profile.public(profile), blocks, warning)
1037
+ return _grouped_yaml(index_profile.public(profile), blocks, " ".join(warnings + notes))
1017
1038
 
1018
1039
 
1019
1040
  def _perform_project_index(arguments, job_id, preview=False):
@@ -1717,11 +1738,31 @@ def _project_action_locked(arguments):
1717
1738
  return {"project": _project_payload(project_store.get(key))}
1718
1739
 
1719
1740
 
1741
+ def _for_agent(result):
1742
+ """register/status for an agent: the project without the full result of every past job, the folder structure of
1743
+ the scope and the profile groups (one project returned 128 thousand characters, more than an MCP client shows);
1744
+ scope_summary keeps include/exclude. The projects screen keeps the full payload."""
1745
+ project = result.get("project") if isinstance(result, dict) else None
1746
+ if isinstance(project, dict) and isinstance(project.get("jobs"), list):
1747
+ project["jobs"] = [{key: job.get(key) for key in ("job_id", "status", "kind", "phase", "updated_at", "warning",
1748
+ "error") if job.get(key) not in (None, "")}
1749
+ for job in project["jobs"][:5]]
1750
+ if isinstance(project, dict):
1751
+ if isinstance(project.get("preview"), dict):
1752
+ project["preview"] = {key: project["preview"].get(key)
1753
+ for key in ("included_files", "eligible_files", "bytes", "skipped")}
1754
+ project.pop("scope", None)
1755
+ return result
1756
+
1757
+
1720
1758
  def _handle_project_manage(arguments):
1721
1759
  # MCP não abre janelas; o seletor nativo pertence apenas à interface local.
1722
1760
  if isinstance(arguments, dict) and arguments.get("action") == "pick":
1723
1761
  raise ValueError("Use register with project_root or pick the folder from the tray.")
1724
- return _to_json_sanitized(_project_action(arguments))
1762
+ result = _project_action(arguments)
1763
+ if isinstance(arguments, dict) and arguments.get("action") in ("register", "status"):
1764
+ result = _for_agent(result)
1765
+ return _to_json_sanitized(result)
1725
1766
 
1726
1767
 
1727
1768
  def _enqueue_monitored_project(project_id, **options):
@@ -3221,10 +3262,9 @@ def _web_route(tool, url):
3221
3262
  if problem:
3222
3263
  return {"redirect": False, "reason": f"gateway unavailable: {problem}"}
3223
3264
  if tool == "WebFetch":
3224
- return {"redirect": True, "reason": "Use the web_fetch tool from the smart-tool MCP (url + prompt): the mini model reads "
3225
- "the page, the answer comes back short and the page is cached for 24 h."}
3226
- return {"redirect": True, "reason": "Use the web_search tool from the smart-tool MCP (query + query_en): several sources, "
3227
- "two languages and a cache shared across sessions."}
3265
+ return {"redirect": True, "reason": "the mini model reads the page, the answer comes back short and the page is "
3266
+ "cached for 24 h."}
3267
+ return {"redirect": True, "reason": "several sources, two languages and a cache shared across sessions."}
3228
3268
 
3229
3269
 
3230
3270
  HOOK_MAX_BODY_BYTES = 4 * 1024 * 1024
@@ -3308,6 +3348,7 @@ def _dispatch(method, params, session_id):
3308
3348
  "protocolVersion": PROTOCOL_VERSION,
3309
3349
  "capabilities": {"tools": {}},
3310
3350
  "serverInfo": {"name": "smart-tool", "version": version.VERSION},
3351
+ "instructions": SERVER_INSTRUCTIONS,
3311
3352
  }
3312
3353
  if method == "tools/list":
3313
3354
  return {"tools": TOOLS}
package/tray.py CHANGED
@@ -20,8 +20,10 @@ import pystray
20
20
 
21
21
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
22
  import autostart
23
+ import config
23
24
  import daemon_launcher
24
25
  import update_check
26
+ import version
25
27
 
26
28
  POLL_INTERVAL_S = 45
27
29
  UPDATE_CHECK_INTERVAL_S = 24 * 3600
@@ -110,6 +112,9 @@ def _poll_loop(icon):
110
112
 
111
113
 
112
114
  def _update_loop(icon):
115
+ """Checks npm at start and every 24 h. At start (logon, before agent sessions use the daemon) a newer version is
116
+ installed right away when auto_update is on; later checks only notify and offer it in the menu."""
117
+ first = True
113
118
  while True:
114
119
  try:
115
120
  latest = update_check.newer_version()
@@ -118,19 +123,56 @@ def _update_loop(icon):
118
123
  if latest and latest != _state["update"]:
119
124
  _state["update"] = latest
120
125
  icon.update_menu()
121
- icon.notify(f"Smart Tool {latest} is available. Update with: {update_check.UPDATE_COMMAND}", "Smart Tool")
126
+ if first and config.auto_update() and update_check.should_auto_update(latest):
127
+ icon.notify(f"Updating Smart Tool to {latest}...", "Smart Tool")
128
+ update_check.run_update(latest, automatic=True)
129
+ else:
130
+ icon.notify(f"Smart Tool {latest} is available: use \"Update to {latest}\" in this menu.", "Smart Tool")
131
+ first = False
122
132
  time.sleep(UPDATE_CHECK_INTERVAL_S)
123
133
 
124
134
 
125
135
  def _update_label(item):
126
- return f"Update to {_state['update']} (copy command)"
136
+ return f"Update to {_state['update']}" if _state["update"] else "Check for updates"
137
+
138
+
139
+ _update_busy = threading.Lock()
140
+
141
+
142
+ def _update_now(icon, item):
143
+ """Menu action: installs the known newer version, or checks npm first and says when there is none."""
144
+ if not _update_busy.acquire(blocking=False):
145
+ return
146
+
147
+ def run():
148
+ try:
149
+ latest = _state["update"] or update_check.newer_version()
150
+ if not latest:
151
+ icon.notify(f"Smart Tool {version.VERSION} is the latest version.", "Smart Tool")
152
+ return
153
+ _state["update"] = latest
154
+ icon.update_menu()
155
+ update_check.run_update(latest, automatic=False)
156
+ except Exception as exc:
157
+ icon.notify(f"Could not update: {exc}", "Smart Tool")
158
+ finally:
159
+ _update_busy.release()
160
+ threading.Thread(target=run, daemon=True).start()
161
+
127
162
 
163
+ def _auto_update_checked(item):
164
+ try:
165
+ return config.auto_update()
166
+ except Exception:
167
+ return False
128
168
 
129
- def _copy_update_command(icon, item):
130
- import subprocess
131
- subprocess.run(["clip.exe"], input=update_check.UPDATE_COMMAND.encode("ascii"), check=True,
132
- creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
133
- icon.notify(f"Copied: {update_check.UPDATE_COMMAND}. Paste it in a terminal.", "Smart Tool")
169
+
170
+ def _toggle_auto_update(icon, item):
171
+ try:
172
+ config.update_config(lambda cfg: cfg.__setitem__("auto_update", not config.auto_update(cfg)))
173
+ except Exception as exc:
174
+ icon.notify(f"Could not change automatic updates: {exc}", "Smart Tool")
175
+ icon.update_menu()
134
176
 
135
177
 
136
178
  def _open_setup(icon, item):
@@ -227,7 +269,8 @@ def main():
227
269
  pystray.MenuItem("Index a folder…", _add_project),
228
270
  pystray.MenuItem("Open settings", _open_setup),
229
271
  pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
230
- pystray.MenuItem(_update_label, _copy_update_command, visible=lambda item: bool(_state["update"])),
272
+ pystray.MenuItem(_update_label, _update_now),
273
+ pystray.MenuItem("Update automatically", _toggle_auto_update, checked=_auto_update_checked),
231
274
  pystray.MenuItem("Quit", _quit),
232
275
  ),
233
276
  )
package/update_check.py CHANGED
@@ -1,12 +1,19 @@
1
- """Newer Smart Tool release on npm, for the tray notice. Updating is running the install command again."""
1
+ """Newer Smart Tool release on npm and the update itself: the tray runs the same install command a user would, in a
2
+ visible console, at startup (once per version, when auto_update is on) or on demand from its menu."""
2
3
  import json
4
+ import os
5
+ import subprocess
6
+ import time
3
7
  import urllib.request
4
8
 
9
+ import atomic_io
10
+ import paths
5
11
  import version
6
12
 
7
13
  PACKAGE = "@allansantos-dev/smart-tool"
8
14
  REGISTRY_URL = "https://registry.npmjs.org/@allansantos-dev%2Fsmart-tool/latest"
9
- UPDATE_COMMAND = f"npx {PACKAGE}@latest install"
15
+ UPDATE_COMMAND = f"npx --yes {PACKAGE}@latest install"
16
+ STATE_PATH = os.path.join(paths.DATA_DIR, "update-state.json")
10
17
 
11
18
 
12
19
  def _parts(value):
@@ -20,3 +27,30 @@ def newer_version(timeout=10):
20
27
  with urllib.request.urlopen(request, timeout=timeout) as response:
21
28
  latest = json.load(response)["version"]
22
29
  return latest if _parts(latest) > _parts(version.VERSION) else None
30
+
31
+
32
+ def attempted():
33
+ """Version the last automatic update went for, or None."""
34
+ try:
35
+ with open(STATE_PATH, encoding="utf-8") as stream:
36
+ return json.load(stream).get("attempted")
37
+ except FileNotFoundError:
38
+ return None
39
+
40
+
41
+ def should_auto_update(latest):
42
+ """An automatic update runs once per version: when it fails, the installer restores the previous version and
43
+ restarts the tray, which would otherwise try the same version again on every start."""
44
+ return bool(latest) and attempted() != latest
45
+
46
+
47
+ def run_update(latest, automatic):
48
+ """Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
49
+ the tray). The console closes on success and stays open with the error on failure."""
50
+ if automatic:
51
+ os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
52
+ atomic_io.write_secret_text(STATE_PATH, json.dumps({"attempted": latest, "at": time.time()}))
53
+ flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
54
+ command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
55
+ subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
56
+ cwd=os.path.expanduser("~"))
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.4"
4
+ VERSION = "0.9.6"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7