@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +251 -0
  32. package/install_runtime.py +249 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +35 -6
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const RUNTIME_DIR = join(homedir(), ".smart-tool", "runtime");
11
+ const UV_VERSION = "0.12.23";
12
+ const UV_ASSET = "uv-x86_64-pc-windows-msvc.zip";
13
+ const UV_SHA256 = "75d05de6762778c31ee183398de7dd15093fad0ed90b1f236d8205ea5ec00c90";
14
+ const UV_DIR = join(RUNTIME_DIR, `uv-${UV_VERSION}`);
15
+ const UV_EXE = join(UV_DIR, "uv.exe");
16
+ const PYTHON_VERSION = "3.12";
17
+ const PYTHON_DIR = join(RUNTIME_DIR, "python");
18
+ const USAGE = `Usage: npx @allansantos-dev/smart-tool install [--target DIR] [--no-start] [--skip-deps]
19
+
20
+ Installs or updates Smart Tool for this Windows user. Run it again with @latest to update.`;
21
+
22
+ function fail(message) {
23
+ console.error(`smart-tool: ${message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function run(command, args, options = {}) {
28
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true, ...options });
29
+ if (result.error) fail(`${command} did not start: ${result.error.message}`);
30
+ if (result.status !== 0) fail(`${command} ${args.join(" ")} exited with ${result.status}: ${(result.stderr || result.stdout || "").trim().slice(-2000)}`);
31
+ return (result.stdout || "").trim();
32
+ }
33
+
34
+ async function ensureUv() {
35
+ if (existsSync(UV_EXE)) return;
36
+ const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}`;
37
+ console.log(`Downloading uv ${UV_VERSION} (Python manager)...`);
38
+ const response = await fetch(url);
39
+ if (!response.ok) fail(`download of ${url} failed: HTTP ${response.status}`);
40
+ const archive = Buffer.from(await response.arrayBuffer());
41
+ const digest = createHash("sha256").update(archive).digest("hex");
42
+ if (digest !== UV_SHA256) fail(`${UV_ASSET} checksum mismatch (expected ${UV_SHA256}, got ${digest}).`);
43
+ const scratch = mkdtempSync(join(tmpdir(), "smart-tool-uv-"));
44
+ try {
45
+ const zip = join(scratch, UV_ASSET);
46
+ writeFileSync(zip, archive);
47
+ mkdirSync(UV_DIR, { recursive: true });
48
+ run(join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe"), ["-xf", zip, "-C", UV_DIR]);
49
+ } finally {
50
+ rmSync(scratch, { recursive: true, force: true });
51
+ }
52
+ if (!existsSync(UV_EXE)) fail(`uv.exe not found in ${UV_DIR} after extracting ${UV_ASSET}.`);
53
+ }
54
+
55
+ function ensurePython() {
56
+ const env = { ...process.env, UV_PYTHON_INSTALL_DIR: PYTHON_DIR };
57
+ console.log(`Preparing Python ${PYTHON_VERSION} in ${PYTHON_DIR}...`);
58
+ run(UV_EXE, ["python", "install", PYTHON_VERSION, "--no-bin", "--no-registry"], { env });
59
+ const python = run(UV_EXE, ["python", "find", "--managed-python", "--no-python-downloads", PYTHON_VERSION], { env });
60
+ if (!python.toLowerCase().startsWith(PYTHON_DIR.toLowerCase())) fail(`uv returned a Python outside ${PYTHON_DIR}: ${python}`);
61
+ return python;
62
+ }
63
+
64
+ async function main() {
65
+ const [command, ...rest] = process.argv.slice(2);
66
+ if (command === "--version" || command === "-v") {
67
+ console.log(JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")).version);
68
+ return;
69
+ }
70
+ if (command !== "install") {
71
+ console.log(USAGE);
72
+ process.exit(command === undefined || command === "--help" || command === "-h" ? 0 : 1);
73
+ }
74
+ if (process.platform !== "win32" || process.arch !== "x64") fail(`Smart Tool runs on Windows x64 only (this is ${process.platform}-${process.arch}).`);
75
+ await ensureUv();
76
+ const python = ensurePython();
77
+ const installer = spawnSync(python, [join(PACKAGE_DIR, "install.py"), "--skip-tests", ...rest], { stdio: "inherit", cwd: PACKAGE_DIR });
78
+ if (installer.error) fail(`the installer did not start: ${installer.error.message}`);
79
+ process.exit(installer.status ?? 1);
80
+ }
81
+
82
+ main().catch((error) => fail(error.stack || String(error)));
@@ -0,0 +1,232 @@
1
+ """Cliente do navegador residente (web_adapters/browser/browser_service.py): um processo para todos os consumidores."""
2
+ import concurrent.futures
3
+ import json
4
+ import os
5
+ import queue
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ import uuid
10
+
11
+ START_TIMEOUT_S = 45
12
+ START_FAILURE_COOLDOWN_S = 60
13
+ SERVICE_IDLE_S = 300
14
+ # Restart instead of writing to a service that may be exiting on its own idle timer.
15
+ IDLE_RESTART_MARGIN_S = 15
16
+ _IS_WINDOWS = os.name == "nt"
17
+
18
+
19
+ class BrowserCaptcha(RuntimeError):
20
+ pass
21
+
22
+
23
+ class BrowserSkipped(RuntimeError):
24
+ """Self-imposed limit (queue, pacing, pause, startup): not a failure of the search engine."""
25
+
26
+
27
+ class BrowserExited(RuntimeError):
28
+ pass
29
+
30
+
31
+ def _kill_on_close_job():
32
+ import ctypes
33
+ from ctypes import wintypes
34
+
35
+ class IoCounters(ctypes.Structure):
36
+ _fields_ = [(name, ctypes.c_ulonglong) for name in
37
+ ("read_ops", "write_ops", "other_ops", "read_bytes", "write_bytes", "other_bytes")]
38
+
39
+ class BasicLimits(ctypes.Structure):
40
+ _fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong),
41
+ ("flags", wintypes.DWORD), ("min_ws", ctypes.c_size_t), ("max_ws", ctypes.c_size_t),
42
+ ("active_processes", wintypes.DWORD), ("affinity", ctypes.c_size_t),
43
+ ("priority", wintypes.DWORD), ("scheduling", wintypes.DWORD)]
44
+
45
+ class ExtendedLimits(ctypes.Structure):
46
+ _fields_ = [("basic", BasicLimits), ("io", IoCounters), ("process_memory", ctypes.c_size_t),
47
+ ("job_memory", ctypes.c_size_t), ("peak_process_memory", ctypes.c_size_t),
48
+ ("peak_job_memory", ctypes.c_size_t)]
49
+
50
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
51
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
52
+ job = kernel32.CreateJobObjectW(None, None)
53
+ if not job:
54
+ raise ctypes.WinError(ctypes.get_last_error())
55
+ limits = ExtendedLimits()
56
+ limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
57
+ if not kernel32.SetInformationJobObject(wintypes.HANDLE(job), 9, ctypes.byref(limits), ctypes.sizeof(limits)):
58
+ raise ctypes.WinError(ctypes.get_last_error())
59
+ return kernel32, job
60
+
61
+
62
+ class BrowserService:
63
+ def __init__(self, executable, script):
64
+ self.executable = executable
65
+ self.script = script
66
+ self.lock = threading.Lock()
67
+ self.start_lock = threading.Lock()
68
+ self.proc = None
69
+ self.writes = None
70
+ self.pending = {}
71
+ self.stderr_tail = ""
72
+ self.last_used = 0.0
73
+ self.start_failure = None
74
+ self.job = None
75
+
76
+ def _alive(self):
77
+ return self.proc is not None and self.proc.poll() is None
78
+
79
+ def _ensure_started(self, deadline):
80
+ with self.lock:
81
+ if self._alive() and time.monotonic() - self.last_used < SERVICE_IDLE_S - IDLE_RESTART_MARGIN_S:
82
+ return self.proc, self.writes
83
+ if not self.start_lock.acquire(timeout=max(0, deadline - time.monotonic())):
84
+ raise BrowserSkipped("resident browser still opening at the deadline")
85
+ try:
86
+ with self.lock:
87
+ if self._alive() and time.monotonic() - self.last_used < SERVICE_IDLE_S - IDLE_RESTART_MARGIN_S:
88
+ return self.proc, self.writes
89
+ stale, self.proc = self.proc, None
90
+ if stale is not None:
91
+ self._kill(stale)
92
+ if self.start_failure and time.monotonic() - self.start_failure[0] < START_FAILURE_COOLDOWN_S:
93
+ raise RuntimeError(self.start_failure[1])
94
+ try:
95
+ proc, writes = self._start()
96
+ except RuntimeError as exc:
97
+ self.start_failure = (time.monotonic(), str(exc))
98
+ raise
99
+ self.start_failure = None
100
+ with self.lock:
101
+ self.proc, self.writes, self.last_used = proc, writes, time.monotonic()
102
+ return proc, writes
103
+ finally:
104
+ self.start_lock.release()
105
+
106
+ def _start(self):
107
+ flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if _IS_WINDOWS else 0
108
+ proc = subprocess.Popen([self.executable, self.script], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
109
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
110
+ creationflags=flags)
111
+ if _IS_WINDOWS:
112
+ self._bind_to_daemon(proc)
113
+ ready = concurrent.futures.Future()
114
+ writes = queue.Queue()
115
+ threading.Thread(target=self._read_stderr, args=(proc,), daemon=True).start()
116
+ threading.Thread(target=self._read_stdout, args=(proc, ready), daemon=True).start()
117
+ threading.Thread(target=self._write_stdin, args=(proc, writes), daemon=True).start()
118
+ try:
119
+ ready.result(timeout=START_TIMEOUT_S)
120
+ except Exception as exc:
121
+ self._kill(proc)
122
+ raise RuntimeError(f"resident browser failed to start: {exc}; {self.stderr_tail[-500:]}") from None
123
+ return proc, writes
124
+
125
+ def _bind_to_daemon(self, proc):
126
+ # Kill-on-close job: the browser tree dies with the daemon even when the daemon is killed hard.
127
+ try:
128
+ if self.job is None:
129
+ self.job = _kill_on_close_job()
130
+ kernel32, job = self.job
131
+ import ctypes
132
+ from ctypes import wintypes
133
+ if not kernel32.AssignProcessToJobObject(wintypes.HANDLE(job), wintypes.HANDLE(proc._handle)):
134
+ raise ctypes.WinError(ctypes.get_last_error())
135
+ except OSError as exc:
136
+ self.stderr_tail += f"\nprocess outside the job object: {exc}\n"
137
+
138
+ def _write_stdin(self, proc, writes):
139
+ while True:
140
+ line = writes.get()
141
+ if line is None:
142
+ return
143
+ try:
144
+ proc.stdin.write(line)
145
+ proc.stdin.flush()
146
+ except (OSError, ValueError):
147
+ return
148
+
149
+ def _read_stdout(self, proc, ready):
150
+ for line in proc.stdout:
151
+ try:
152
+ message = json.loads(line)
153
+ except ValueError:
154
+ continue
155
+ if not isinstance(message, dict):
156
+ continue
157
+ if message.get("ready"):
158
+ if not ready.done():
159
+ ready.set_result(True)
160
+ continue
161
+ with self.lock:
162
+ _owner, future = self.pending.pop(message.get("id"), (None, None))
163
+ if future and not future.done():
164
+ future.set_result(message)
165
+ reason = f"resident browser exited (code {proc.wait()}): {self.stderr_tail[-500:]}"
166
+ if not ready.done():
167
+ ready.set_exception(RuntimeError(reason))
168
+ with self.lock:
169
+ orphans = [key for key, (owner, _) in self.pending.items() if owner is proc]
170
+ futures = [self.pending.pop(key)[1] for key in orphans]
171
+ for future in futures:
172
+ if not future.done():
173
+ future.set_exception(BrowserExited(reason))
174
+
175
+ def _read_stderr(self, proc):
176
+ for line in proc.stderr:
177
+ self.stderr_tail = (self.stderr_tail + line)[-4000:]
178
+
179
+ def _kill(self, proc):
180
+ if proc.poll() is None:
181
+ if _IS_WINDOWS:
182
+ subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True,
183
+ creationflags=subprocess.CREATE_NO_WINDOW)
184
+ else:
185
+ proc.kill()
186
+ try:
187
+ proc.wait(timeout=10)
188
+ except subprocess.TimeoutExpired:
189
+ pass
190
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
191
+ try:
192
+ stream.close()
193
+ except (OSError, ValueError):
194
+ pass
195
+
196
+ def search(self, engine, query, num, timeout_s):
197
+ deadline = time.monotonic() + timeout_s
198
+ proc, writes = self._ensure_started(deadline)
199
+ left = deadline - time.monotonic()
200
+ if left < 1:
201
+ raise BrowserSkipped(f"{engine}: deadline used up while opening the browser")
202
+ request_id = uuid.uuid4().hex
203
+ future = concurrent.futures.Future()
204
+ with self.lock:
205
+ self.pending[request_id] = (proc, future)
206
+ self.last_used = time.monotonic()
207
+ writes.put(json.dumps({"id": request_id, "engine": engine, "query": query, "num": num,
208
+ "expires_at": time.time() + left - 0.5}) + "\n")
209
+ try:
210
+ message = future.result(timeout=left + 2)
211
+ except concurrent.futures.TimeoutError:
212
+ with self.lock:
213
+ self.pending.pop(request_id, None)
214
+ raise TimeoutError(f"{engine}: resident browser did not respond in time") from None
215
+ finally:
216
+ with self.lock:
217
+ self.last_used = time.monotonic()
218
+ if message.get("captcha"):
219
+ raise BrowserCaptcha(message["error"])
220
+ if message.get("skipped"):
221
+ raise BrowserSkipped(message["error"])
222
+ if "error" in message:
223
+ raise RuntimeError(message["error"])
224
+ return message["results"]
225
+
226
+ def close(self):
227
+ with self.lock:
228
+ proc, writes, self.proc = self.proc, self.writes, None
229
+ if writes is not None:
230
+ writes.put(None)
231
+ if proc is not None:
232
+ self._kill(proc)
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ """Probe de capacidades do Smart Tool (grafo/embedding/rerank), rodado no boot do daemon.
3
+
4
+ Grafo: client-pure, mesmo contrato do Brain Server (`claude-code-boss/.../graph/daemon.js`) —
5
+ lê o registry auto-anunciado em `~/.mcp-memory/run/daemon.json` e faz health-check em
6
+ `{url}/health` (200 ou 503 = viva). Nunca spawna o daemon do grafo; se ausente/offline, falha
7
+ aberto (graph=False), sem lançar exceção.
8
+ """
9
+ import json
10
+ import os
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ import model_client
15
+
16
+ BRAIN_RUN_DIR = os.environ.get("MCP_RUN_DIR") or os.path.join(
17
+ os.path.expanduser("~"), ".mcp-memory", "run"
18
+ )
19
+ BRAIN_REGISTRY_PATH = os.path.join(BRAIN_RUN_DIR, "daemon.json")
20
+
21
+
22
+ class Capabilities:
23
+ def __init__(self, graph, embed, rerank):
24
+ self.graph = graph
25
+ self.embed = embed
26
+ self.rerank = rerank
27
+
28
+ def to_dict(self):
29
+ return {"graph": self.graph, "embed": self.embed, "rerank": self.rerank}
30
+
31
+
32
+ def _probe_graph(timeout=2):
33
+ try:
34
+ with open(BRAIN_REGISTRY_PATH, "r", encoding="utf-8") as f:
35
+ info = json.load(f)
36
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
37
+ return False
38
+ url = info.get("url") if isinstance(info, dict) else None
39
+ if not url:
40
+ return False
41
+ try:
42
+ with urllib.request.urlopen(url.rstrip("/") + "/health", timeout=timeout) as resp:
43
+ return resp.status in (200, 503)
44
+ except (urllib.error.URLError, OSError, ValueError):
45
+ return False
46
+
47
+
48
+ def _probe_embed_rerank():
49
+ token = model_client.get_token()
50
+ data = model_client.fetch("/v1/models", token)
51
+ ids = [row.get("id", "") for row in data.get("data", [])]
52
+ embed = any("embed" in i.lower() for i in ids)
53
+ rerank = any("rerank" in i.lower() for i in ids)
54
+ return embed, rerank
55
+
56
+
57
+ def probe():
58
+ graph = _probe_graph()
59
+ try:
60
+ embed, rerank = _probe_embed_rerank()
61
+ except Exception:
62
+ embed, rerank = False, False
63
+ return Capabilities(graph, embed, rerank)
@@ -0,0 +1,192 @@
1
+ import json
2
+ import os
3
+ import secrets
4
+ import shutil
5
+ import sys
6
+ import time
7
+
8
+ import daemon_launcher
9
+ import endpoint_sync
10
+
11
+ HOOK_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hooks", "pretool_router.py")
12
+ MARKER = "pretool_router.py"
13
+ HTTP_PATH = "/hooks/pretool"
14
+ HTTP_TIMEOUT_S = 15
15
+ HOME = os.path.expanduser("~")
16
+ PREVIEW_TTL_S = 600
17
+ CLIENTS = {
18
+ "claude": {"label": "Claude Code", "names": {"claude-code"}, "files": [os.path.join(endpoint_sync.claude_dir(), "settings.json")],
19
+ "docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|WebSearch|WebFetch", "http": True},
20
+ "codex": {"label": "Codex", "prefix": "codex", "files": [os.path.join(HOME, ".codex", "hooks.json"), os.path.join(HOME, ".codex", "config.toml")],
21
+ "docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash",
22
+ "after_install": "Codex only runs new hooks after the user approves the definition in /hooks."},
23
+ "cursor": {"label": "Cursor", "prefix": "cursor", "files": [os.path.join(HOME, ".cursor", "hooks.json")],
24
+ "docs": "https://cursor.com/docs/agent/hooks", "event": "preToolUse", "matcher": "Shell|Read|Grep",
25
+ "blocked": "In Cursor, `allow` approves the tool without the user and empty output blocks: there is no documented neutral "
26
+ "response, so the hook is not installed automatically."},
27
+ }
28
+ _PREVIEWS = {}
29
+
30
+
31
+ def identify(client_info):
32
+ name = str((client_info or {}).get("name") or "").casefold()
33
+ return next((key for key, spec in CLIENTS.items()
34
+ if name in spec.get("names", ()) or (spec.get("prefix") and name.startswith(spec["prefix"]))), None)
35
+
36
+
37
+ def _our_hook(hook):
38
+ return isinstance(hook, dict) and (MARKER in str(hook.get("command", "")) or HTTP_PATH in str(hook.get("url", "")))
39
+
40
+
41
+ def _ours(group):
42
+ return isinstance(group, dict) and isinstance(group.get("hooks"), list) and any(map(_our_hook, group["hooks"]))
43
+
44
+
45
+ def _without_ours(groups):
46
+ """Drops only our hook entries; a group shared with other hooks keeps them and disappears only when emptied."""
47
+ kept = []
48
+ for group in groups:
49
+ if not _ours(group):
50
+ kept.append(group)
51
+ continue
52
+ others = [hook for hook in group["hooks"] if not _our_hook(hook)]
53
+ if others:
54
+ kept.append({**group, "hooks": others})
55
+ return kept
56
+
57
+
58
+ def installed(client):
59
+ """Our hook is present with the current matcher; an entry with an older matcher counts as missing."""
60
+ spec = CLIENTS[client]
61
+ for path in spec["files"]:
62
+ try:
63
+ with open(path, encoding="utf-8") as stream:
64
+ text = stream.read()
65
+ except FileNotFoundError:
66
+ continue
67
+ if MARKER not in text and HTTP_PATH not in text:
68
+ continue
69
+ if not path.endswith(".json"):
70
+ return True
71
+ data = json.loads(text)
72
+ hooks = data.get("hooks") if isinstance(data, dict) else None
73
+ groups = hooks.get(spec["event"]) if isinstance(hooks, dict) else None
74
+ expected = _handler(client)
75
+ return isinstance(groups, list) and any(
76
+ group.get("matcher") == spec["matcher"] and expected in group["hooks"] for group in groups if _ours(group))
77
+ return False
78
+
79
+
80
+ def status(client_info):
81
+ client = identify(client_info)
82
+ if client is None:
83
+ return {"client": None, "name": (client_info or {}).get("name"), "installed": None, "error": None}
84
+ spec = CLIENTS[client]
85
+ try:
86
+ state, error = installed(client), None
87
+ except (OSError, ValueError) as exc:
88
+ state, error = None, f"{type(exc).__name__}: {exc}"[:200]
89
+ return {"client": client, "name": client_info.get("name"), "installed": state, "error": error,
90
+ "files": spec["files"], "docs": spec["docs"]}
91
+
92
+
93
+ def notice(state):
94
+ if state["client"] is None:
95
+ return (f"Unrecognized MCP client ({state['name'] or 'no clientInfo'}): Smart Tool does not check the hook "
96
+ "that redirects Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this client.")
97
+ spec = CLIENTS[state["client"]]
98
+ if state["error"]:
99
+ return f"Could not check the Smart Tool hook in {spec['files'][0]}: {state['error']}"
100
+ if state["installed"]:
101
+ return None
102
+ action = spec.get("blocked") or ("If the user asks, use project_manage action=install_hook_preview, show the "
103
+ "change and confirm with action=install_hook (makes a backup).")
104
+ return (f"Smart Tool hook missing or outdated in {spec['label']} ({spec['files'][0]}): Grep/Read/Bash are not "
105
+ f"redirected to smart_search, nor WebSearch/WebFetch to web_search/web_fetch. {action} Docs: {spec['docs']}")
106
+
107
+
108
+ def _shell_safe(path):
109
+ """Unquoted path that runs the same in cmd, PowerShell and bash (the Codex docs don't name the shell): forward
110
+ slashes (bash eats backslashes), no spaces (8.3 short name), no quotes (PowerShell reads a quoted head as a string)."""
111
+ if " " in path and sys.platform == "win32":
112
+ import ctypes
113
+ buffer = ctypes.create_unicode_buffer(1024)
114
+ if ctypes.windll.kernel32.GetShortPathNameW(path, buffer, len(buffer)):
115
+ path = buffer.value
116
+ if " " in path:
117
+ raise ValueError(f"Path contains a space and has no 8.3 short name ({path}); the hook was not installed.")
118
+ return path.replace("\\", "/")
119
+
120
+
121
+ def _command(client):
122
+ return f"{_shell_safe(sys.executable)} {_shell_safe(HOOK_SCRIPT)} --client {client}"
123
+
124
+
125
+ def _handler(client):
126
+ """Claude Code posts the payload straight to the warm daemon (`type: "http"`, no process per tool call); other
127
+ clients run the thin command that forwards to the same route."""
128
+ if CLIENTS[client].get("http"):
129
+ registry = daemon_launcher.read_registry()
130
+ if not registry or not registry.get("url"):
131
+ raise ValueError("Smart Tool daemon has no registered URL; start the daemon before installing the hook.")
132
+ return {"type": "http", "url": f"{registry['url']}{HTTP_PATH}?client={client}", "timeout": HTTP_TIMEOUT_S}
133
+ return {"type": "command", "command": _command(client), "timeout": HTTP_TIMEOUT_S}
134
+
135
+
136
+ def _plan(client_info):
137
+ client = identify(client_info)
138
+ if client is None:
139
+ raise ValueError(f"Unrecognized MCP client ({(client_info or {}).get('name') or 'no clientInfo'}); "
140
+ "the hook is only installed for Claude Code or Codex, from that client's own session.")
141
+ spec = CLIENTS[client]
142
+ if spec.get("blocked"):
143
+ raise ValueError(spec["blocked"] + f" Configure it manually if you want: {spec['docs']}")
144
+ path = os.path.realpath(spec["files"][0])
145
+ try:
146
+ with open(path, encoding="utf-8") as stream:
147
+ data = json.load(stream)
148
+ except FileNotFoundError:
149
+ data = {}
150
+ if not isinstance(data, dict) or not isinstance(data.setdefault("hooks", {}), dict):
151
+ raise ValueError(f"{path} does not have the expected format (object with 'hooks'); nothing was changed.")
152
+ groups = data["hooks"].setdefault(spec["event"], [])
153
+ if not isinstance(groups, list):
154
+ raise ValueError(f"{path}: hooks.{spec['event']} is not a list; nothing was changed.")
155
+ groups[:] = _without_ours(groups)
156
+ entry = {"matcher": spec["matcher"], "hooks": [_handler(client)]}
157
+ return client, spec, path, data, groups, entry
158
+
159
+
160
+ def preview(client_info):
161
+ client, spec, path, _data, _groups, entry = _plan(client_info)
162
+ if installed(client):
163
+ return {"client": client, "installed": True, "file": path, "change": None}
164
+ token = secrets.token_urlsafe(12)
165
+ _PREVIEWS[token] = {"client": client, "at": time.time()}
166
+ return {"client": client, "installed": False, "file": path, "event": spec["event"], "change": entry,
167
+ "token": token, "expires_s": PREVIEW_TTL_S, "after_install": spec.get("after_install")}
168
+
169
+
170
+ def install(client_info, token, confirm):
171
+ planned = _PREVIEWS.pop(token or "", None)
172
+ client, spec, path, data, groups, entry = _plan(client_info)
173
+ if confirm is not True or not planned or planned["client"] != client or time.time() - planned["at"] > PREVIEW_TTL_S:
174
+ raise ValueError("Installing requires the install_hook_preview token from this session (valid for 10 min) and confirm=true.")
175
+ if installed(client):
176
+ return {"client": client, "installed": True, "changed": False, "file": path}
177
+ groups.append(entry)
178
+ backup = None
179
+ if os.path.exists(path):
180
+ backup = f"{path}.smart-tool-backup-{time.strftime('%Y%m%d-%H%M%S')}"
181
+ shutil.copy2(path, backup)
182
+ os.makedirs(os.path.dirname(path), exist_ok=True)
183
+ temporary = path + ".smart-tool-tmp"
184
+ with open(temporary, "w", encoding="utf-8") as stream:
185
+ json.dump(data, stream, indent=2, ensure_ascii=False)
186
+ os.replace(temporary, path)
187
+ if not installed(client):
188
+ if backup:
189
+ shutil.copy2(backup, path)
190
+ raise RuntimeError(f"The hook does not appear in {path} after writing; the file was restored.")
191
+ return {"client": client, "installed": True, "changed": True, "file": path, "backup": backup,
192
+ "after_install": spec.get("after_install")}