@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.
- package/CHANGELOG.md +50 -23
- package/NOTICE +32 -32
- package/README.md +117 -116
- package/atomic_io.py +94 -94
- package/bin/smart-tool.mjs +5 -1
- package/client_hooks.py +192 -192
- package/code_graph_java.mjs +49 -49
- package/code_graph_js.cjs +135 -135
- package/duplicates.py +313 -313
- package/embedding_cache.py +99 -99
- package/endpoint_sync.py +179 -179
- package/gateway.py +220 -220
- package/hook_decision.py +96 -96
- package/index_inventory.py +75 -75
- package/indexer.py +878 -878
- package/install.py +113 -11
- package/install_runtime.py +101 -35
- package/integration_metrics.py +82 -82
- package/local_embedder.py +172 -172
- package/model_defaults.py +70 -70
- package/package.json +35 -35
- package/project_insights_ui.py +38 -38
- package/project_monitor.py +204 -204
- package/project_store.py +220 -220
- package/projects_ui.py +146 -146
- package/requirements.txt +8 -8
- package/research_cache.py +171 -171
- package/research_metrics.py +110 -110
- package/router.py +582 -582
- package/secret_store.py +121 -121
- package/setup_ui.py +1011 -986
- package/smart_tool_daemon.py +3604 -3604
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +69 -0
- package/web_adapters/node/ddg-search.mjs +89 -89
- package/web_adapters/node/open-websearch-adapter.mjs +77 -77
- package/web_adapters/node/package.json +12 -12
- package/web_adapters/node/wikipedia-search.mjs +44 -44
- package/web_search_adapters.py +193 -193
- package/web_search_external.py +288 -288
- package/web_search_health.py +88 -88
package/embedding_cache.py
CHANGED
|
@@ -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
|