@allansantos-dev/smart-tool 0.9.0 → 0.9.2

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 (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. package/web_search_health.py +88 -88
@@ -1,99 +1,99 @@
1
- """Reúso de vetores por texto exato e contrato; não guarda texto nem faz rede."""
2
- import array
3
- import concurrent.futures
4
- import contextvars
5
- import hashlib
6
- import json
7
- import math
8
- import os
9
- import sqlite3
10
- import time
11
-
12
- import project_identity
13
-
14
-
15
- class VectorCache:
16
- def __init__(self, root, directory, contract):
17
- os.makedirs(directory, exist_ok=True)
18
- path = os.path.join(directory, project_identity.project_id(root) + '.vectors.sqlite3')
19
- self.db = sqlite3.connect(path, timeout=10)
20
- self.db.execute('PRAGMA secure_delete=ON')
21
- self.db.execute('CREATE TABLE IF NOT EXISTS vectors(key TEXT PRIMARY KEY, vector BLOB NOT NULL, used_at REAL NOT NULL)')
22
- self.contract = json.dumps(contract, sort_keys=True, ensure_ascii=False)
23
- self.dimensions = contract.get('dimensions')
24
- self.hits = self.misses = self.characters = 0
25
-
26
- def key(self, text):
27
- return hashlib.sha256((self.contract + '\0' + text).encode('utf-8')).hexdigest()
28
-
29
- def put(self, text, vector):
30
- if not self.dimensions or len(vector) != self.dimensions:
31
- return
32
- packed = array.array('f', vector)
33
- if not all(math.isfinite(value) for value in packed):
34
- raise ValueError('Embedding does not fit in float32; cache not written.')
35
- self.db.execute('INSERT OR REPLACE INTO vectors VALUES(?,?,?)',
36
- (self.key(text), packed.tobytes(), time.time()))
37
-
38
- def embed(self, texts, embed_batch, validate, use_cache=True, split=None, workers=1):
39
- """Cache lookups and writes stay on the calling thread (sqlite); only embed_batch runs in workers."""
40
- output = [None] * len(texts)
41
- missing = {}
42
- for i, text in enumerate(texts):
43
- row = self.db.execute('SELECT vector FROM vectors WHERE key=?', (self.key(text),)).fetchone() if use_cache else None
44
- if row and self.dimensions:
45
- vector = array.array('f'); vector.frombytes(row[0])
46
- validate(vector, self.dimensions)
47
- output[i] = list(vector)
48
- self.hits += 1
49
- else:
50
- missing.setdefault(text, []).append(i)
51
- if missing:
52
- unique = list(missing)
53
- batches = split(unique) if split else [unique]
54
- dimensions = [self.dimensions]
55
-
56
- def store(part, vectors):
57
- # Each batch is cached as soon as it returns: a later failure never discards paid embeddings.
58
- if len(vectors) != len(part):
59
- raise ValueError('Vector count differs from the chunks sent.')
60
- dimensions[0] = dimensions[0] or (len(vectors[0]) if vectors else None)
61
- for vector in vectors:
62
- validate(vector, dimensions[0])
63
- for text, vector in zip(part, vectors):
64
- self.put(text, vector)
65
- positions = missing[text]
66
- for i in positions:
67
- output[i] = vector
68
- self.hits += len(positions) - 1
69
- self.misses += len(part)
70
- self.characters += sum(map(len, part))
71
- self.db.commit()
72
-
73
- if workers > 1 and len(batches) > 1:
74
- pool = concurrent.futures.ThreadPoolExecutor(min(workers, len(batches)))
75
- futures = {pool.submit(contextvars.copy_context().run, embed_batch, part): part for part in batches}
76
- error = None
77
- try:
78
- for future in concurrent.futures.as_completed(futures):
79
- if future.cancelled():
80
- continue
81
- try:
82
- store(futures[future], list(future.result()))
83
- except Exception as exc:
84
- if error is None:
85
- error = exc
86
- for other in futures:
87
- other.cancel()
88
- finally:
89
- pool.shutdown(wait=True, cancel_futures=True)
90
- if error is not None:
91
- raise error
92
- else:
93
- for part in batches:
94
- store(part, list(embed_batch(part)))
95
- return output
96
-
97
- def close(self):
98
- self.db.commit()
99
- self.db.close()
1
+ """Reúso de vetores por texto exato e contrato; não guarda texto nem faz rede."""
2
+ import array
3
+ import concurrent.futures
4
+ import contextvars
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import sqlite3
10
+ import time
11
+
12
+ import project_identity
13
+
14
+
15
+ class VectorCache:
16
+ def __init__(self, root, directory, contract):
17
+ os.makedirs(directory, exist_ok=True)
18
+ path = os.path.join(directory, project_identity.project_id(root) + '.vectors.sqlite3')
19
+ self.db = sqlite3.connect(path, timeout=10)
20
+ self.db.execute('PRAGMA secure_delete=ON')
21
+ self.db.execute('CREATE TABLE IF NOT EXISTS vectors(key TEXT PRIMARY KEY, vector BLOB NOT NULL, used_at REAL NOT NULL)')
22
+ self.contract = json.dumps(contract, sort_keys=True, ensure_ascii=False)
23
+ self.dimensions = contract.get('dimensions')
24
+ self.hits = self.misses = self.characters = 0
25
+
26
+ def key(self, text):
27
+ return hashlib.sha256((self.contract + '\0' + text).encode('utf-8')).hexdigest()
28
+
29
+ def put(self, text, vector):
30
+ if not self.dimensions or len(vector) != self.dimensions:
31
+ return
32
+ packed = array.array('f', vector)
33
+ if not all(math.isfinite(value) for value in packed):
34
+ raise ValueError('Embedding does not fit in float32; cache not written.')
35
+ self.db.execute('INSERT OR REPLACE INTO vectors VALUES(?,?,?)',
36
+ (self.key(text), packed.tobytes(), time.time()))
37
+
38
+ def embed(self, texts, embed_batch, validate, use_cache=True, split=None, workers=1):
39
+ """Cache lookups and writes stay on the calling thread (sqlite); only embed_batch runs in workers."""
40
+ output = [None] * len(texts)
41
+ missing = {}
42
+ for i, text in enumerate(texts):
43
+ row = self.db.execute('SELECT vector FROM vectors WHERE key=?', (self.key(text),)).fetchone() if use_cache else None
44
+ if row and self.dimensions:
45
+ vector = array.array('f'); vector.frombytes(row[0])
46
+ validate(vector, self.dimensions)
47
+ output[i] = list(vector)
48
+ self.hits += 1
49
+ else:
50
+ missing.setdefault(text, []).append(i)
51
+ if missing:
52
+ unique = list(missing)
53
+ batches = split(unique) if split else [unique]
54
+ dimensions = [self.dimensions]
55
+
56
+ def store(part, vectors):
57
+ # Each batch is cached as soon as it returns: a later failure never discards paid embeddings.
58
+ if len(vectors) != len(part):
59
+ raise ValueError('Vector count differs from the chunks sent.')
60
+ dimensions[0] = dimensions[0] or (len(vectors[0]) if vectors else None)
61
+ for vector in vectors:
62
+ validate(vector, dimensions[0])
63
+ for text, vector in zip(part, vectors):
64
+ self.put(text, vector)
65
+ positions = missing[text]
66
+ for i in positions:
67
+ output[i] = vector
68
+ self.hits += len(positions) - 1
69
+ self.misses += len(part)
70
+ self.characters += sum(map(len, part))
71
+ self.db.commit()
72
+
73
+ if workers > 1 and len(batches) > 1:
74
+ pool = concurrent.futures.ThreadPoolExecutor(min(workers, len(batches)))
75
+ futures = {pool.submit(contextvars.copy_context().run, embed_batch, part): part for part in batches}
76
+ error = None
77
+ try:
78
+ for future in concurrent.futures.as_completed(futures):
79
+ if future.cancelled():
80
+ continue
81
+ try:
82
+ store(futures[future], list(future.result()))
83
+ except Exception as exc:
84
+ if error is None:
85
+ error = exc
86
+ for other in futures:
87
+ other.cancel()
88
+ finally:
89
+ pool.shutdown(wait=True, cancel_futures=True)
90
+ if error is not None:
91
+ raise error
92
+ else:
93
+ for part in batches:
94
+ store(part, list(embed_batch(part)))
95
+ return output
96
+
97
+ def close(self):
98
+ self.db.commit()
99
+ self.db.close()
package/endpoint_sync.py CHANGED
@@ -1,179 +1,179 @@
1
- """Aponta os clientes para a porta em que o daemon subiu, só quando ela muda.
2
-
3
- O daemon é dono da porta: depois do bind, reescreve apenas as URLs do smart-tool que os clientes guardam (MCP do
4
- Claude Code em ~/.claude.json, hook HTTP em ~/.claude/settings.json, MCP do Codex em ~/.codex/config.toml). Nada roda
5
- por prompt ou por sessão; o Claude Code relê hooks editados pelo file watcher, o MCP já conectado precisa de /mcp.
6
- Cada arquivo é relido logo antes da gravação atômica: o Claude Code reescreve o ~/.claude.json com frequência.
7
- """
8
- import json
9
- import os
10
- import re
11
- import shutil
12
- import subprocess
13
- import time
14
- import urllib.parse
15
-
16
- SERVER = "smart-tool"
17
- HOOK_PATH = "/hooks/pretool"
18
- _CODEX_URL_RE = re.compile(r'(\[mcp_servers\.smart-tool\][^\[]*?\burl\s*=\s*")http://127\.0\.0\.1:\d+(/mcp")')
19
-
20
-
21
- def _home(*parts):
22
- return os.path.join(os.path.expanduser("~"), *parts)
23
-
24
-
25
- def claude_dir():
26
- """Claude Code keeps settings.json in CLAUDE_CONFIG_DIR when set, else ~/.claude."""
27
- return os.environ.get("CLAUDE_CONFIG_DIR") or _home(".claude")
28
-
29
-
30
- def claude_json():
31
- """User-scope MCP servers: $CLAUDE_CONFIG_DIR/.claude.json when set, else ~/.claude.json."""
32
- custom = os.environ.get("CLAUDE_CONFIG_DIR")
33
- return os.path.join(custom, ".claude.json") if custom else _home(".claude.json")
34
-
35
-
36
- def _rebase(url, base):
37
- parsed = urllib.parse.urlsplit(url)
38
- if parsed.hostname != "127.0.0.1":
39
- return url
40
- return urllib.parse.urlunsplit(urllib.parse.urlsplit(base)[:2] + parsed[2:])
41
-
42
-
43
- def _claude_mcp(text, base):
44
- data = json.loads(text)
45
- entry = (data.get("mcpServers") or {}).get(SERVER) if isinstance(data, dict) else None
46
- if not isinstance(entry, dict) or not isinstance(entry.get("url"), str):
47
- return None
48
- new = _rebase(entry["url"], base)
49
- if new == entry["url"]:
50
- return None
51
- entry["url"] = new
52
- return json.dumps(data, indent=2, ensure_ascii=False)
53
-
54
-
55
- def _claude_hook(text, base):
56
- data = json.loads(text)
57
- changed = False
58
- groups = ((data.get("hooks") or {}).get("PreToolUse") or []) if isinstance(data, dict) else []
59
- for group in groups if isinstance(groups, list) else []:
60
- for hook in (group.get("hooks") or []) if isinstance(group, dict) else []:
61
- if isinstance(hook, dict) and HOOK_PATH in str(hook.get("url", "")):
62
- new = _rebase(hook["url"], base)
63
- changed, hook["url"] = changed or new != hook["url"], new
64
- return json.dumps(data, indent=2, ensure_ascii=False) if changed else None
65
-
66
-
67
- def _codex_mcp(text, base):
68
- port = urllib.parse.urlsplit(base).port
69
- new = _CODEX_URL_RE.sub(lambda m: f"{m.group(1)}http://127.0.0.1:{port}{m.group(2)}", text, count=1)
70
- return new if new != text else None
71
-
72
-
73
- def targets():
74
- return ((claude_json(), _claude_mcp), (os.path.join(claude_dir(), "settings.json"), _claude_hook),
75
- (_home(".codex", "config.toml"), _codex_mcp))
76
-
77
-
78
- _CODEX_ANY_URL_RE = re.compile(r'\[mcp_servers\.smart-tool\][^\[]*?\burl\s*=\s*"([^"]+)"')
79
-
80
-
81
- def registrations():
82
- """smart-tool MCP URL kept by each client, or None when it is not registered there."""
83
- found = {"claude": None, "codex": None}
84
- for attempt in range(2):
85
- try:
86
- with open(claude_json(), encoding="utf-8") as stream:
87
- entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
88
- found["claude"] = entry.get("url") if isinstance(entry, dict) else None
89
- break
90
- except ValueError:
91
- # Claude Code rewrites the file all the time; one more read gets past a half-written copy.
92
- time.sleep(0.2)
93
- except (OSError, AttributeError):
94
- break
95
- try:
96
- with open(_home(".codex", "config.toml"), encoding="utf-8") as stream:
97
- match = _CODEX_ANY_URL_RE.search(stream.read())
98
- found["codex"] = match.group(1) if match else None
99
- except OSError:
100
- pass
101
- return found
102
-
103
-
104
- REGISTER_TIMEOUT_S = 60
105
-
106
-
107
- def register_command(client, base):
108
- """Official CLI command that registers the smart-tool MCP server (Claude Code: user scope, every project)."""
109
- url = f"{base}/mcp"
110
- if client == "claude":
111
- return ["claude", "mcp", "add", "--transport", "http", SERVER, url, "--scope", "user"]
112
- if client == "codex":
113
- return ["codex", "mcp", "add", SERVER, "--url", url]
114
- raise ValueError(f"Client without MCP registration: {client!r}")
115
-
116
-
117
- def register(client, base):
118
- """Runs the client's own CLI (it owns its config file; ~/.claude.json is rewritten by Claude Code all the time)."""
119
- command = register_command(client, base)
120
- executable = shutil.which(command[0])
121
- if not executable:
122
- raise RuntimeError(f"CLI '{command[0]}' not found in Smart Tool's PATH. Register it manually: {' '.join(command)}")
123
- flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
124
- try:
125
- proc = subprocess.Popen([executable, *command[1:]], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
126
- stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
127
- creationflags=flags)
128
- except OSError as exc:
129
- raise RuntimeError(f"'{' '.join(command)}' did not start: {exc}") from None
130
- try:
131
- stdout, stderr = proc.communicate(timeout=REGISTER_TIMEOUT_S)
132
- except subprocess.TimeoutExpired:
133
- # npm shims are cmd.exe -> node: killing only cmd.exe leaves node holding the pipes forever.
134
- if os.name == "nt":
135
- subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, creationflags=flags)
136
- else:
137
- proc.kill()
138
- proc.communicate(timeout=10)
139
- raise RuntimeError(f"'{' '.join(command)}' did not finish in {REGISTER_TIMEOUT_S}s.") from None
140
- if proc.returncode != 0:
141
- detail = (stderr or stdout).strip()[-400:]
142
- raise RuntimeError(f"'{' '.join(command)}' failed (code {proc.returncode}): {detail}")
143
- if registrations().get(client) != f"{base}/mcp":
144
- raise RuntimeError(f"'{' '.join(command)}' finished, but the registration did not appear in the client's configuration.")
145
- return " ".join(command)
146
-
147
-
148
- def known_port():
149
- """Port the Claude Code MCP entry already points to, so a fallback bind can reuse it without any rewrite."""
150
- try:
151
- parsed = urllib.parse.urlsplit(registrations()["claude"] or "")
152
- return parsed.port if parsed.hostname == "127.0.0.1" else None
153
- except ValueError:
154
- return None
155
-
156
-
157
- def sync(base):
158
- """Rewrites the smart-tool URLs that point to another local port. Returns the files changed."""
159
- changed = []
160
- for path, rewrite in targets():
161
- for _ in range(2):
162
- try:
163
- with open(path, encoding="utf-8") as stream:
164
- text = stream.read()
165
- except FileNotFoundError:
166
- break
167
- new = rewrite(text, base)
168
- if new is None:
169
- break
170
- with open(path, encoding="utf-8") as stream:
171
- if stream.read() != text:
172
- continue
173
- temporary = f"{path}.smart-tool-tmp"
174
- with open(temporary, "w", encoding="utf-8", newline="") as stream:
175
- stream.write(new)
176
- os.replace(temporary, path)
177
- changed.append(path)
178
- break
179
- return changed
1
+ """Aponta os clientes para a porta em que o daemon subiu, só quando ela muda.
2
+
3
+ O daemon é dono da porta: depois do bind, reescreve apenas as URLs do smart-tool que os clientes guardam (MCP do
4
+ Claude Code em ~/.claude.json, hook HTTP em ~/.claude/settings.json, MCP do Codex em ~/.codex/config.toml). Nada roda
5
+ por prompt ou por sessão; o Claude Code relê hooks editados pelo file watcher, o MCP já conectado precisa de /mcp.
6
+ Cada arquivo é relido logo antes da gravação atômica: o Claude Code reescreve o ~/.claude.json com frequência.
7
+ """
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import time
14
+ import urllib.parse
15
+
16
+ SERVER = "smart-tool"
17
+ HOOK_PATH = "/hooks/pretool"
18
+ _CODEX_URL_RE = re.compile(r'(\[mcp_servers\.smart-tool\][^\[]*?\burl\s*=\s*")http://127\.0\.0\.1:\d+(/mcp")')
19
+
20
+
21
+ def _home(*parts):
22
+ return os.path.join(os.path.expanduser("~"), *parts)
23
+
24
+
25
+ def claude_dir():
26
+ """Claude Code keeps settings.json in CLAUDE_CONFIG_DIR when set, else ~/.claude."""
27
+ return os.environ.get("CLAUDE_CONFIG_DIR") or _home(".claude")
28
+
29
+
30
+ def claude_json():
31
+ """User-scope MCP servers: $CLAUDE_CONFIG_DIR/.claude.json when set, else ~/.claude.json."""
32
+ custom = os.environ.get("CLAUDE_CONFIG_DIR")
33
+ return os.path.join(custom, ".claude.json") if custom else _home(".claude.json")
34
+
35
+
36
+ def _rebase(url, base):
37
+ parsed = urllib.parse.urlsplit(url)
38
+ if parsed.hostname != "127.0.0.1":
39
+ return url
40
+ return urllib.parse.urlunsplit(urllib.parse.urlsplit(base)[:2] + parsed[2:])
41
+
42
+
43
+ def _claude_mcp(text, base):
44
+ data = json.loads(text)
45
+ entry = (data.get("mcpServers") or {}).get(SERVER) if isinstance(data, dict) else None
46
+ if not isinstance(entry, dict) or not isinstance(entry.get("url"), str):
47
+ return None
48
+ new = _rebase(entry["url"], base)
49
+ if new == entry["url"]:
50
+ return None
51
+ entry["url"] = new
52
+ return json.dumps(data, indent=2, ensure_ascii=False)
53
+
54
+
55
+ def _claude_hook(text, base):
56
+ data = json.loads(text)
57
+ changed = False
58
+ groups = ((data.get("hooks") or {}).get("PreToolUse") or []) if isinstance(data, dict) else []
59
+ for group in groups if isinstance(groups, list) else []:
60
+ for hook in (group.get("hooks") or []) if isinstance(group, dict) else []:
61
+ if isinstance(hook, dict) and HOOK_PATH in str(hook.get("url", "")):
62
+ new = _rebase(hook["url"], base)
63
+ changed, hook["url"] = changed or new != hook["url"], new
64
+ return json.dumps(data, indent=2, ensure_ascii=False) if changed else None
65
+
66
+
67
+ def _codex_mcp(text, base):
68
+ port = urllib.parse.urlsplit(base).port
69
+ new = _CODEX_URL_RE.sub(lambda m: f"{m.group(1)}http://127.0.0.1:{port}{m.group(2)}", text, count=1)
70
+ return new if new != text else None
71
+
72
+
73
+ def targets():
74
+ return ((claude_json(), _claude_mcp), (os.path.join(claude_dir(), "settings.json"), _claude_hook),
75
+ (_home(".codex", "config.toml"), _codex_mcp))
76
+
77
+
78
+ _CODEX_ANY_URL_RE = re.compile(r'\[mcp_servers\.smart-tool\][^\[]*?\burl\s*=\s*"([^"]+)"')
79
+
80
+
81
+ def registrations():
82
+ """smart-tool MCP URL kept by each client, or None when it is not registered there."""
83
+ found = {"claude": None, "codex": None}
84
+ for attempt in range(2):
85
+ try:
86
+ with open(claude_json(), encoding="utf-8") as stream:
87
+ entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
88
+ found["claude"] = entry.get("url") if isinstance(entry, dict) else None
89
+ break
90
+ except ValueError:
91
+ # Claude Code rewrites the file all the time; one more read gets past a half-written copy.
92
+ time.sleep(0.2)
93
+ except (OSError, AttributeError):
94
+ break
95
+ try:
96
+ with open(_home(".codex", "config.toml"), encoding="utf-8") as stream:
97
+ match = _CODEX_ANY_URL_RE.search(stream.read())
98
+ found["codex"] = match.group(1) if match else None
99
+ except OSError:
100
+ pass
101
+ return found
102
+
103
+
104
+ REGISTER_TIMEOUT_S = 60
105
+
106
+
107
+ def register_command(client, base):
108
+ """Official CLI command that registers the smart-tool MCP server (Claude Code: user scope, every project)."""
109
+ url = f"{base}/mcp"
110
+ if client == "claude":
111
+ return ["claude", "mcp", "add", "--transport", "http", SERVER, url, "--scope", "user"]
112
+ if client == "codex":
113
+ return ["codex", "mcp", "add", SERVER, "--url", url]
114
+ raise ValueError(f"Client without MCP registration: {client!r}")
115
+
116
+
117
+ def register(client, base):
118
+ """Runs the client's own CLI (it owns its config file; ~/.claude.json is rewritten by Claude Code all the time)."""
119
+ command = register_command(client, base)
120
+ executable = shutil.which(command[0])
121
+ if not executable:
122
+ raise RuntimeError(f"CLI '{command[0]}' not found in Smart Tool's PATH. Register it manually: {' '.join(command)}")
123
+ flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
124
+ try:
125
+ proc = subprocess.Popen([executable, *command[1:]], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
126
+ stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
127
+ creationflags=flags)
128
+ except OSError as exc:
129
+ raise RuntimeError(f"'{' '.join(command)}' did not start: {exc}") from None
130
+ try:
131
+ stdout, stderr = proc.communicate(timeout=REGISTER_TIMEOUT_S)
132
+ except subprocess.TimeoutExpired:
133
+ # npm shims are cmd.exe -> node: killing only cmd.exe leaves node holding the pipes forever.
134
+ if os.name == "nt":
135
+ subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, creationflags=flags)
136
+ else:
137
+ proc.kill()
138
+ proc.communicate(timeout=10)
139
+ raise RuntimeError(f"'{' '.join(command)}' did not finish in {REGISTER_TIMEOUT_S}s.") from None
140
+ if proc.returncode != 0:
141
+ detail = (stderr or stdout).strip()[-400:]
142
+ raise RuntimeError(f"'{' '.join(command)}' failed (code {proc.returncode}): {detail}")
143
+ if registrations().get(client) != f"{base}/mcp":
144
+ raise RuntimeError(f"'{' '.join(command)}' finished, but the registration did not appear in the client's configuration.")
145
+ return " ".join(command)
146
+
147
+
148
+ def known_port():
149
+ """Port the Claude Code MCP entry already points to, so a fallback bind can reuse it without any rewrite."""
150
+ try:
151
+ parsed = urllib.parse.urlsplit(registrations()["claude"] or "")
152
+ return parsed.port if parsed.hostname == "127.0.0.1" else None
153
+ except ValueError:
154
+ return None
155
+
156
+
157
+ def sync(base):
158
+ """Rewrites the smart-tool URLs that point to another local port. Returns the files changed."""
159
+ changed = []
160
+ for path, rewrite in targets():
161
+ for _ in range(2):
162
+ try:
163
+ with open(path, encoding="utf-8") as stream:
164
+ text = stream.read()
165
+ except FileNotFoundError:
166
+ break
167
+ new = rewrite(text, base)
168
+ if new is None:
169
+ break
170
+ with open(path, encoding="utf-8") as stream:
171
+ if stream.read() != text:
172
+ continue
173
+ temporary = f"{path}.smart-tool-tmp"
174
+ with open(temporary, "w", encoding="utf-8", newline="") as stream:
175
+ stream.write(new)
176
+ os.replace(temporary, path)
177
+ changed.append(path)
178
+ break
179
+ return changed