@allansantos-dev/smart-tool 0.9.1 → 0.9.3
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 +77 -0
- package/README.md +27 -2
- package/affected_tests.py +292 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +75 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +148 -13
- package/edit_preview.py +92 -0
- package/hook_decision.py +96 -2
- package/index_profile.py +1 -1
- package/install_runtime.py +37 -5
- package/package.json +1 -1
- package/router.py +4 -2
- package/setup_ui.py +113 -40
- package/smart_tool_daemon.py +108 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +59 -10
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
package/edit_preview.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The files an agent edit would produce, computed before the edit runs: Claude Code Edit, MultiEdit and Write, and
|
|
2
|
+
Codex apply_patch (*** Begin Patch / Update File / Add File). Returns (absolute path, text before or None, text after)
|
|
3
|
+
for each file it can rebuild; an edit it cannot rebuild yields nothing, so a hook built on it never blocks wrongly."""
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _read(path):
|
|
8
|
+
try:
|
|
9
|
+
with open(path, encoding="utf-8") as stream:
|
|
10
|
+
return stream.read()
|
|
11
|
+
except (OSError, UnicodeDecodeError):
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _replace(text, old, new, every):
|
|
16
|
+
if not old or old not in text:
|
|
17
|
+
return None
|
|
18
|
+
return text.replace(old, new) if every else text.replace(old, new, 1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _claude(tool, data):
|
|
22
|
+
path = data.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not os.path.isabs(path):
|
|
24
|
+
return []
|
|
25
|
+
before = _read(path)
|
|
26
|
+
if tool == "Write":
|
|
27
|
+
content = data.get("content")
|
|
28
|
+
return [(path, before, content)] if isinstance(content, str) else []
|
|
29
|
+
if before is None:
|
|
30
|
+
return []
|
|
31
|
+
edits = data.get("edits") if tool == "MultiEdit" else [data]
|
|
32
|
+
after = before
|
|
33
|
+
for edit in edits or []:
|
|
34
|
+
after = _replace(after, edit.get("old_string"), edit.get("new_string", ""), edit.get("replace_all") is True)
|
|
35
|
+
if after is None:
|
|
36
|
+
return []
|
|
37
|
+
return [(path, before, after)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _apply_hunks(text, hunks):
|
|
41
|
+
lines = text.splitlines()
|
|
42
|
+
for old, new in hunks:
|
|
43
|
+
if not old:
|
|
44
|
+
lines.extend(new)
|
|
45
|
+
continue
|
|
46
|
+
at = next((i for i in range(len(lines) - len(old) + 1) if lines[i:i + len(old)] == old), None)
|
|
47
|
+
if at is None:
|
|
48
|
+
return None
|
|
49
|
+
lines[at:at + len(old)] = new
|
|
50
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") or not text else "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _codex(patch, cwd):
|
|
54
|
+
if not isinstance(patch, str) or "*** Begin Patch" not in patch:
|
|
55
|
+
return []
|
|
56
|
+
files, current = [], None
|
|
57
|
+
for raw in patch.splitlines():
|
|
58
|
+
if raw.startswith(("*** Update File: ", "*** Add File: ")):
|
|
59
|
+
current = {"action": raw.split(":", 1)[0][4:], "path": raw.split(":", 1)[1].strip(), "hunks": [[[], []]]}
|
|
60
|
+
files.append(current)
|
|
61
|
+
elif raw.startswith(("*** Delete File: ", "*** End Patch", "*** Move to: ")) or current is None:
|
|
62
|
+
current = None if raw.startswith(("*** Delete File: ", "*** End Patch")) else current
|
|
63
|
+
elif raw.startswith("@@"):
|
|
64
|
+
current["hunks"].append([[], []])
|
|
65
|
+
elif raw.startswith("+"):
|
|
66
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
67
|
+
elif raw.startswith("-"):
|
|
68
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
69
|
+
elif raw.startswith(" ") or raw == "":
|
|
70
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
71
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
72
|
+
results = []
|
|
73
|
+
for entry in files:
|
|
74
|
+
path = entry["path"] if os.path.isabs(entry["path"]) else os.path.join(cwd or os.getcwd(), entry["path"])
|
|
75
|
+
hunks = [hunk for hunk in entry["hunks"] if hunk[0] or hunk[1]]
|
|
76
|
+
if entry["action"] == "Add File":
|
|
77
|
+
results.append((path, None, "\n".join(line for hunk in hunks for line in hunk[1]) + "\n"))
|
|
78
|
+
continue
|
|
79
|
+
before = _read(path)
|
|
80
|
+
after = _apply_hunks(before, hunks) if before is not None else None
|
|
81
|
+
if after is not None:
|
|
82
|
+
results.append((path, before, after))
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def preview(tool_name, tool_input, cwd=None):
|
|
87
|
+
data = tool_input if isinstance(tool_input, dict) else {}
|
|
88
|
+
if tool_name in ("Edit", "MultiEdit", "Write") and "file_path" in data:
|
|
89
|
+
return _claude(tool_name, data)
|
|
90
|
+
if tool_name in ("apply_patch", "Edit", "Write") and "command" in data:
|
|
91
|
+
return _codex(data.get("command"), cwd)
|
|
92
|
+
return []
|
package/hook_decision.py
CHANGED
|
@@ -10,11 +10,20 @@ no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
+
import code_graph
|
|
14
|
+
import code_impact
|
|
13
15
|
import config
|
|
16
|
+
import doc_check
|
|
17
|
+
import duplicates
|
|
18
|
+
import edit_preview
|
|
14
19
|
import endpoint_sync
|
|
20
|
+
import project_store
|
|
15
21
|
import router
|
|
16
22
|
|
|
17
23
|
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
24
|
+
EDIT_TOOLS = ("Edit", "MultiEdit", "Write", "apply_patch")
|
|
25
|
+
MAX_DOC_FINDINGS = 5
|
|
26
|
+
MAX_DUPLICATE_FINDINGS = 3
|
|
18
27
|
SMART_TOOL_SERVER = "smart-tool"
|
|
19
28
|
REASON_MAX_CHARS = 200
|
|
20
29
|
|
|
@@ -55,9 +64,94 @@ def advise(reason):
|
|
|
55
64
|
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
65
|
|
|
57
66
|
|
|
67
|
+
def _project_of(path):
|
|
68
|
+
target = os.path.normcase(os.path.abspath(path))
|
|
69
|
+
roots = [p["root"] for p in project_store.all_projects()
|
|
70
|
+
if target.startswith(os.path.normcase(os.path.abspath(p["root"])) + os.sep)]
|
|
71
|
+
return max(roots, key=len) if roots else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _impact_line(root, rel, name):
|
|
75
|
+
"""Callers and tests from the code graph, only when its analysis is already cached: the hook never waits for one."""
|
|
76
|
+
if not root or code_graph.cached_symbols(root)[0] is None:
|
|
77
|
+
return ""
|
|
78
|
+
try:
|
|
79
|
+
found = code_impact.impact(root, f"{rel}::{name}", limit=3)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return ""
|
|
82
|
+
counts = found["counts"]
|
|
83
|
+
tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
|
|
84
|
+
calls = f"; tests calling it: {tests}" if tests else ""
|
|
85
|
+
return f" {counts['callers']} caller(s){calls}; project_manage affected_tests lists every test to run after the edit."
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _doc_lines(findings):
|
|
89
|
+
lines = []
|
|
90
|
+
for root, rel, item in findings[:MAX_DOC_FINDINGS]:
|
|
91
|
+
if item["issue"] == "missing":
|
|
92
|
+
text = f"{item['name']} ({rel}:{item['start']}) has no docstring: write one (purpose, parameters, return)."
|
|
93
|
+
else:
|
|
94
|
+
text = (f"{item['name']} ({rel}:{item['start']}) changed its signature: update its docstring if the "
|
|
95
|
+
f"contract changed.")
|
|
96
|
+
lines.append(text + _impact_line(root, rel, item["name"]))
|
|
97
|
+
if len(findings) > MAX_DOC_FINDINGS:
|
|
98
|
+
lines.append(f"{len(findings) - MAX_DOC_FINDINGS} more function(s) in the same situation.")
|
|
99
|
+
return "Smart Tool docs: " + " ".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _duplicate_lines(findings):
|
|
103
|
+
lines = []
|
|
104
|
+
for rel, item in findings[:MAX_DUPLICATE_FINDINGS]:
|
|
105
|
+
existing = item["existing"]
|
|
106
|
+
where = f"{existing['name']} in {existing['path']}:{existing['lines']}"
|
|
107
|
+
if item["type"] == "exact":
|
|
108
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) has the same body as {where}: reuse it instead of a copy.")
|
|
109
|
+
else:
|
|
110
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) is near-identical to {where} (score {item['score']}, "
|
|
111
|
+
f"local names ignored): check whether it can be reused.")
|
|
112
|
+
if len(findings) > MAX_DUPLICATE_FINDINGS:
|
|
113
|
+
lines.append(f"{len(findings) - MAX_DUPLICATE_FINDINGS} more copied function(s).")
|
|
114
|
+
return ("Smart Tool duplicates: " + " ".join(lines)
|
|
115
|
+
+ " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _edit_review(payload, doc_mode, duplicate_mode):
|
|
119
|
+
"""Checks of an edit before it runs. Docstrings follow doc_mode: missing ones deny the edit in require mode and
|
|
120
|
+
become a reminder in remind mode; a documented function whose signature changed always gets a reminder. Copies of
|
|
121
|
+
indexed functions follow duplicate_mode and only add context, never deny."""
|
|
122
|
+
doc_findings, duplicate_findings, failures = [], [], []
|
|
123
|
+
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
124
|
+
if not doc_check.supported(path):
|
|
125
|
+
continue
|
|
126
|
+
root = _project_of(path)
|
|
127
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/") if root else os.path.basename(path)
|
|
128
|
+
edited = doc_check.written(path, before, after)
|
|
129
|
+
if doc_mode != "off":
|
|
130
|
+
doc_findings += [(root, rel, item) for item in doc_check.review(path, before, after, edited)]
|
|
131
|
+
if duplicate_mode != "off" and root and edited["touched"]:
|
|
132
|
+
try:
|
|
133
|
+
duplicate_findings += [(rel, item) for item in duplicates.on_write(root, path, before, after, edited)]
|
|
134
|
+
except (ValueError, RuntimeError) as exc:
|
|
135
|
+
failures.append(f"Smart Tool duplicates check skipped: {exc}")
|
|
136
|
+
parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
|
|
137
|
+
([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
|
|
138
|
+
if not parts:
|
|
139
|
+
return {}
|
|
140
|
+
message = " ".join(parts)
|
|
141
|
+
if doc_mode == "require" and any(item["issue"] == "missing" for _root, _rel, item in doc_findings):
|
|
142
|
+
return deny(message + " Redo the edit with the docstring.")
|
|
143
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}
|
|
144
|
+
|
|
145
|
+
|
|
58
146
|
def decide(payload, client, web_route):
|
|
59
|
-
"""Hook output for one PreToolUse payload
|
|
147
|
+
"""Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
|
|
148
|
+
(warn | off), the other tools follow hook_mode (redirect | advise | off)."""
|
|
60
149
|
cfg = config.load_config()
|
|
150
|
+
if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
|
|
151
|
+
doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
|
|
152
|
+
if doc_mode == "off" and duplicate_mode == "off":
|
|
153
|
+
return {}
|
|
154
|
+
return _edit_review(payload, doc_mode, duplicate_mode)
|
|
61
155
|
mode = config.hook_mode(cfg)
|
|
62
156
|
if mode == "off" or not isinstance(payload, dict):
|
|
63
157
|
return {}
|
|
@@ -82,7 +176,7 @@ def _route(payload, client, web_route, cfg):
|
|
|
82
176
|
return {}
|
|
83
177
|
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
178
|
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
-
router.CLIENT
|
|
179
|
+
router.CLIENT.set(client)
|
|
86
180
|
try:
|
|
87
181
|
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
182
|
except Exception as exc:
|
package/index_profile.py
CHANGED
|
@@ -17,7 +17,7 @@ DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
|
|
17
17
|
_NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
|
|
18
18
|
_DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
|
|
19
19
|
_TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
|
|
20
|
-
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)
|
|
20
|
+
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
|
|
21
21
|
r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
|
|
22
22
|
_TEST_PROSE = re.compile(r"(^|/)(tests?|__tests__|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)src/test/", re.I)
|
|
23
23
|
_TEXT_LINE = re.compile(r"[A-Za-zÀ-ú]{3,}\s+[A-Za-zÀ-ú]{3,}")
|
package/install_runtime.py
CHANGED
|
@@ -133,7 +133,7 @@ def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, at
|
|
|
133
133
|
except RuntimeError as exc:
|
|
134
134
|
if attempt == attempts:
|
|
135
135
|
raise
|
|
136
|
-
print(f"{
|
|
136
|
+
print(f"{exc}; trying again.", flush=True)
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
def _run_once(args, cwd, env, flags, label, idle_s, stream):
|
|
@@ -179,8 +179,10 @@ def _run_once(args, cwd, env, flags, label, idle_s, stream):
|
|
|
179
179
|
time.sleep(0.5)
|
|
180
180
|
reader.join(timeout=10)
|
|
181
181
|
if process.returncode:
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
if not stream:
|
|
183
|
+
for line in list(tail)[-15:]:
|
|
184
|
+
print(f"{label}: {line}", flush=True)
|
|
185
|
+
raise RuntimeError(f"{label} failed (exit {process.returncode}): {tail[-1] if tail else 'no output'}")
|
|
184
186
|
|
|
185
187
|
|
|
186
188
|
def _check():
|
|
@@ -193,6 +195,8 @@ def _check():
|
|
|
193
195
|
"angular_parser":bool(node and ANGULAR_PARSER.is_file()),
|
|
194
196
|
"browser_python": BROWSER_PYTHON.is_file(),
|
|
195
197
|
"browser_packages": False,
|
|
198
|
+
"camoufox_browser": False,
|
|
199
|
+
"chromium_browser": False,
|
|
196
200
|
}
|
|
197
201
|
if status["browser_python"]:
|
|
198
202
|
try:
|
|
@@ -203,9 +207,36 @@ def _check():
|
|
|
203
207
|
status["browser_packages"] = result.returncode == 0
|
|
204
208
|
except subprocess.TimeoutExpired:
|
|
205
209
|
status["browser_packages"] = False
|
|
210
|
+
if status["browser_packages"]:
|
|
211
|
+
status.update(_browser_executables())
|
|
206
212
|
return status
|
|
207
213
|
|
|
208
214
|
|
|
215
|
+
_BROWSERS_PROBE = """
|
|
216
|
+
import json, os
|
|
217
|
+
from camoufox.pkgman import LAUNCH_FILE, OS_NAME, camoufox_path
|
|
218
|
+
from playwright.sync_api import sync_playwright
|
|
219
|
+
found = {}
|
|
220
|
+
try:
|
|
221
|
+
found["camoufox_browser"] = os.path.isfile(os.path.join(camoufox_path(download_if_missing=False), LAUNCH_FILE[OS_NAME]))
|
|
222
|
+
except Exception:
|
|
223
|
+
found["camoufox_browser"] = False
|
|
224
|
+
with sync_playwright() as playwright:
|
|
225
|
+
found["chromium_browser"] = os.path.isfile(playwright.chromium.executable_path)
|
|
226
|
+
print(json.dumps(found))
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _browser_executables():
|
|
231
|
+
"""Both browsers must exist on disk: a download tool reporting success is not enough."""
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run([str(BROWSER_PYTHON), "-c", _BROWSERS_PROBE], capture_output=True, text=True,
|
|
234
|
+
encoding="utf-8", errors="replace", check=False, timeout=60)
|
|
235
|
+
return json.loads(result.stdout.strip().splitlines()[-1])
|
|
236
|
+
except (subprocess.TimeoutExpired, ValueError, IndexError):
|
|
237
|
+
return {"camoufox_browser": False, "chromium_browser": False}
|
|
238
|
+
|
|
239
|
+
|
|
209
240
|
def _link_existing_browser(existing, link):
|
|
210
241
|
try:
|
|
211
242
|
os.symlink(existing, link, target_is_directory=True)
|
|
@@ -268,9 +299,10 @@ def main():
|
|
|
268
299
|
help="Reuse an existing venv without duplicating files (useful when disk is low)")
|
|
269
300
|
args = parser.parse_args()
|
|
270
301
|
if args.check:
|
|
271
|
-
|
|
302
|
+
status = _check()
|
|
303
|
+
for name, ready in status.items():
|
|
272
304
|
print(f"{name}: {'ok' if ready else 'missing'}")
|
|
273
|
-
return 0 if all(
|
|
305
|
+
return 0 if all(status.values()) else 1
|
|
274
306
|
try:
|
|
275
307
|
install(args.reuse_browser_runtime)
|
|
276
308
|
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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
|
@@ -5,6 +5,7 @@ modelo pequeno se deve deixar passar ou redirecionar pra `smart_search`. Cada de
|
|
|
5
5
|
logada em `router-metrics.jsonl` pra orientar um classificador mais fino depois, com
|
|
6
6
|
base em uso real — não numa suposição a priori.
|
|
7
7
|
"""
|
|
8
|
+
import contextvars
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import paths
|
|
@@ -17,7 +18,8 @@ import index_scope
|
|
|
17
18
|
import indexer
|
|
18
19
|
import web_search_adapters
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
# Per request: the daemon serves each hook call on its own thread, so a module global mixed up clients.
|
|
22
|
+
CLIENT = contextvars.ContextVar("router_client", default=None)
|
|
21
23
|
METRICS_PATH = os.path.join(paths.DATA_DIR, "router-metrics.jsonl")
|
|
22
24
|
|
|
23
25
|
_SYSTEM_PROMPT = (
|
|
@@ -266,7 +268,7 @@ def _log_decision(tool_name, tool_input, decision, reason, extra=None):
|
|
|
266
268
|
"tool_input": _redacted_input(tool_input),
|
|
267
269
|
"decision": decision,
|
|
268
270
|
"reason": reason,
|
|
269
|
-
**({"client": CLIENT} if CLIENT else {}),
|
|
271
|
+
**({"client": CLIENT.get()} if CLIENT.get() else {}),
|
|
270
272
|
}
|
|
271
273
|
record.update(extra or {})
|
|
272
274
|
try:
|
package/setup_ui.py
CHANGED
|
@@ -177,18 +177,31 @@ def handle_integration_get():
|
|
|
177
177
|
mode, mode_error = config.hook_mode(), ""
|
|
178
178
|
except ValueError as exc:
|
|
179
179
|
mode, mode_error = "", str(exc)
|
|
180
|
+
try:
|
|
181
|
+
doc_mode, doc_error = config.doc_mode(), ""
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
doc_mode, doc_error = "", str(exc)
|
|
184
|
+
try:
|
|
185
|
+
duplicate_mode, duplicate_error = config.duplicate_mode(), ""
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
duplicate_mode, duplicate_error = "", str(exc)
|
|
180
188
|
return {"daemon_url": setup_origin(), "clients": clients, "hook_mode": mode, "hook_mode_error": mode_error,
|
|
181
|
-
"hook_modes": list(config.HOOK_MODES)
|
|
189
|
+
"hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
|
|
190
|
+
"doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
|
|
191
|
+
"duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES)}
|
|
182
192
|
|
|
183
193
|
|
|
184
194
|
def handle_integration_save(body):
|
|
185
195
|
"""`hook_mode` sets redirect/advise/off for every client; `register_mcp` runs the client's CLI; hook in two steps:
|
|
186
196
|
`preview` shows the exact change, `install` writes it (with backup)."""
|
|
187
|
-
if isinstance(body, dict) and body.get("action")
|
|
188
|
-
|
|
189
|
-
|
|
197
|
+
if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode"):
|
|
198
|
+
action = body["action"]
|
|
199
|
+
allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
|
|
200
|
+
"duplicate_mode": config.DUPLICATE_MODES}[action]
|
|
201
|
+
if body.get("mode") not in allowed:
|
|
202
|
+
raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
|
|
190
203
|
try:
|
|
191
|
-
config.update_config(lambda cfg: cfg.update(
|
|
204
|
+
config.update_config(lambda cfg: cfg.update({action: body["mode"]}))
|
|
192
205
|
except (OSError, config.ConfigCorruptedError) as exc:
|
|
193
206
|
raise SetupError(str(exc))
|
|
194
207
|
return handle_integration_get()
|
|
@@ -397,9 +410,18 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
397
410
|
.pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
|
|
398
411
|
label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 4px; }
|
|
399
412
|
.hint { display: block; font-size: .76rem; color: var(--muted); margin-top: 3px; }
|
|
400
|
-
select, input[type=text], input[type=url], input[type=number], input[type=password] {
|
|
413
|
+
select, input:not([type]), input[type=text], input[type=url], input[type=number], input[type=password] {
|
|
401
414
|
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
|
402
|
-
background: var(--bg); color: var(--ink); font: .84rem var(--mono); min-width: 0; max-width: 100%; }
|
|
415
|
+
background-color: var(--bg); color: var(--ink); font: .84rem var(--mono); min-width: 0; max-width: 100%; }
|
|
416
|
+
select, input[list] { appearance: none; padding-right: 34px; cursor: pointer;
|
|
417
|
+
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
|
418
|
+
background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%; background-size: 5px 5px; background-repeat: no-repeat; }
|
|
419
|
+
input[list] { cursor: text; }
|
|
420
|
+
input[list]::-webkit-calendar-picker-indicator { opacity: 0; cursor: pointer; }
|
|
421
|
+
select:hover, input[list]:hover { border-color: var(--muted); }
|
|
422
|
+
.code-preview { display: block; margin: 10px 0 0; padding: 12px 14px; max-height: 320px; overflow: auto; white-space: pre;
|
|
423
|
+
font: .78rem/1.5 var(--mono); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
|
|
424
|
+
.plan { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
|
|
403
425
|
input[type=password] { font-family: var(--body); }
|
|
404
426
|
input::placeholder { color: var(--idle); }
|
|
405
427
|
select:focus-visible, input:focus-visible, button:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
|
|
@@ -556,10 +578,23 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
556
578
|
</select>
|
|
557
579
|
<span class="hint" id="hook-mode-hint">Applies to every client on the next tool call; nothing is reinstalled.</span></div>
|
|
558
580
|
<div class="actions"><button type="button" id="btn-save-hook-mode">Save behavior</button></div>
|
|
559
|
-
<div
|
|
560
|
-
|
|
561
|
-
|
|
581
|
+
<div class="field" style="margin-top:16px"><label for="sel-doc-mode">Documentation on edit</label>
|
|
582
|
+
<select id="sel-doc-mode">
|
|
583
|
+
<option value="remind">Remind: the edit runs; the agent is told which touched functions lack a docstring (default)</option>
|
|
584
|
+
<option value="require">Require: an edit that leaves a touched function without a docstring is blocked until it adds one</option>
|
|
585
|
+
<option value="off">Off: no documentation checks</option>
|
|
586
|
+
</select>
|
|
587
|
+
<span class="hint">Only public functions the edit touches (private, nested and override functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle). A documented function is mentioned again only when its signature changes; body-only edits stay silent.</span></div>
|
|
588
|
+
<div class="actions"><button type="button" id="btn-save-doc-mode">Save documentation</button></div>
|
|
589
|
+
<div class="field" style="margin-top:16px"><label for="sel-duplicate-mode">Duplicate functions on edit</label>
|
|
590
|
+
<select id="sel-duplicate-mode">
|
|
591
|
+
<option value="warn">Warn: the edit runs; the agent is told when a written function copies one already in the project (default)</option>
|
|
592
|
+
<option value="off">Off: no duplicate checks on edit</option>
|
|
593
|
+
</select>
|
|
594
|
+
<span class="hint">Compares functions the edit writes with the indexed code: identical bodies, and near-identical ones with only local names changed. Never blocks.</span></div>
|
|
595
|
+
<div class="actions"><button type="button" id="btn-save-duplicate-mode">Save duplicates</button></div>
|
|
562
596
|
<div class="msg" id="agents-msg" role="status"></div>
|
|
597
|
+
<div id="agents-list"></div>
|
|
563
598
|
</section>
|
|
564
599
|
|
|
565
600
|
<section id="providers-section">
|
|
@@ -570,7 +605,10 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
570
605
|
<span class="hint" id="contact-hint">Wikimedia asks tools to identify a contact in the User-Agent. It stays on this machine and is sent only to the search APIs.</span></div>
|
|
571
606
|
<div class="actions"><button type="button" id="btn-save-contact">Save contact</button></div>
|
|
572
607
|
<div class="msg" id="contact-msg" role="status"></div>
|
|
573
|
-
<div
|
|
608
|
+
<div class="field" style="margin-top:18px"><label for="provider-select">Provider</label>
|
|
609
|
+
<select id="provider-select"></select>
|
|
610
|
+
<span class="hint">Pick a provider to see its free tier and set or remove your key for it.</span></div>
|
|
611
|
+
<div id="provider-panel"></div>
|
|
574
612
|
</section>
|
|
575
613
|
</div>
|
|
576
614
|
</div>
|
|
@@ -643,8 +681,10 @@ function renderGatewayFields() {
|
|
|
643
681
|
for (const field of (adapter || { fields: [] }).fields) {
|
|
644
682
|
const wrap = document.createElement("div");
|
|
645
683
|
wrap.className = "field";
|
|
646
|
-
wrap.innerHTML = `<label></label><input autocomplete="off" spellcheck="false"><span class="hint"></span>`;
|
|
684
|
+
wrap.innerHTML = `<label></label><input type="text" autocomplete="off" spellcheck="false"><span class="hint"></span>`;
|
|
647
685
|
const input = wrap.querySelector("input");
|
|
686
|
+
input.id = "gateway-field-" + field.name;
|
|
687
|
+
wrap.querySelector("label").htmlFor = input.id;
|
|
648
688
|
wrap.querySelector("label").textContent = field.label + (field.required ? "" : " (optional)");
|
|
649
689
|
input.dataset.field = field.name;
|
|
650
690
|
input.dataset.secret = field.secret ? "1" : "";
|
|
@@ -831,14 +871,17 @@ $("btn-save-local-models").addEventListener("click", async () => {
|
|
|
831
871
|
}
|
|
832
872
|
});
|
|
833
873
|
|
|
834
|
-
let agentsPlan = null;
|
|
835
874
|
async function loadAgents() {
|
|
836
875
|
try {
|
|
837
876
|
const resp = await fetch("/setup/integration");
|
|
838
877
|
const data = await resp.json();
|
|
839
878
|
if (!resp.ok || data.error) throw new Error(data.error);
|
|
840
879
|
if (document.activeElement !== $("sel-hook-mode")) $("sel-hook-mode").value = data.hook_mode;
|
|
880
|
+
if (document.activeElement !== $("sel-doc-mode")) $("sel-doc-mode").value = data.doc_mode;
|
|
881
|
+
if (document.activeElement !== $("sel-duplicate-mode")) $("sel-duplicate-mode").value = data.duplicate_mode;
|
|
841
882
|
if (data.hook_mode_error) setMsg("agents-msg", "error", data.hook_mode_error);
|
|
883
|
+
if (data.doc_mode_error) setMsg("agents-msg", "error", data.doc_mode_error);
|
|
884
|
+
if (data.duplicate_mode_error) setMsg("agents-msg", "error", data.duplicate_mode_error);
|
|
842
885
|
const list = $("agents-list");
|
|
843
886
|
list.textContent = "";
|
|
844
887
|
for (const item of data.clients) {
|
|
@@ -847,7 +890,9 @@ async function loadAgents() {
|
|
|
847
890
|
const mcp = item.mcp_url ? "MCP registered: " + item.mcp_url : "smart-tool MCP not registered in this client";
|
|
848
891
|
const hook = item.hook_error ? "Hook unreadable: " + item.hook_error : item.hook ? "Hook installed and up to date" : "Hook missing or outdated";
|
|
849
892
|
row.innerHTML = `<div class="provider-head"><strong></strong><span class="state"></span></div><p class="hint mcp"></p><p class="hint mono cmd"></p><p class="hint file"></p>
|
|
850
|
-
<div class="actions"><button type="button" class="register">Register MCP</button><button type="button" class="hook">Install or update hook</button></div
|
|
893
|
+
<div class="actions"><button type="button" class="register">Register MCP</button><button type="button" class="hook">Install or update hook</button></div>
|
|
894
|
+
<div class="plan" hidden><span class="hint plan-file"></span><pre class="code-preview"></pre>
|
|
895
|
+
<div class="actions"><button type="button" class="confirm">Confirm and write</button><button type="button" class="quiet cancel">Cancel</button></div></div>`;
|
|
851
896
|
row.querySelector("strong").textContent = item.label;
|
|
852
897
|
row.querySelector(".state").textContent = hook;
|
|
853
898
|
row.querySelector(".mcp").textContent = mcp;
|
|
@@ -862,34 +907,37 @@ async function loadAgents() {
|
|
|
862
907
|
const result = await post("/setup/integration", { client: item.client, action: "register_mcp" });
|
|
863
908
|
setMsg("agents-msg", "ok", "MCP registered with: " + result.command + ". Reconnect open sessions (/mcp).");
|
|
864
909
|
await loadAgents();
|
|
865
|
-
$("btn-save-hook-mode").addEventListener("click", async () => {
|
|
866
|
-
try {
|
|
867
|
-
await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });
|
|
868
|
-
setMsg("agents-msg", "ok", "Hook behavior saved; it applies on the next tool call.");
|
|
869
|
-
await loadAgents();
|
|
870
|
-
} catch (error) {
|
|
871
|
-
setMsg("agents-msg", "error", error.message);
|
|
872
|
-
}
|
|
873
|
-
});
|
|
874
910
|
} catch (error) {
|
|
875
911
|
setMsg("agents-msg", "error", error.message);
|
|
876
912
|
register.disabled = false;
|
|
877
913
|
}
|
|
878
914
|
});
|
|
879
915
|
const button = row.querySelector(".hook");
|
|
916
|
+
const plan = row.querySelector(".plan");
|
|
880
917
|
button.hidden = Boolean(item.hook);
|
|
881
918
|
button.addEventListener("click", async () => {
|
|
882
919
|
setMsg("agents-msg", "", "Preparing the change…");
|
|
883
920
|
try {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
921
|
+
const preview = await post("/setup/integration", { client: item.client, action: "preview" });
|
|
922
|
+
row.querySelector(".plan-file").textContent = "Entry added to " + preview.file + " (the file is backed up first):";
|
|
923
|
+
row.querySelector(".code-preview").textContent = JSON.stringify(preview.change, null, 2);
|
|
924
|
+
plan.hidden = false;
|
|
925
|
+
button.hidden = true;
|
|
926
|
+
setMsg("agents-msg", "", "");
|
|
927
|
+
row.querySelector(".confirm").onclick = async () => {
|
|
928
|
+
try {
|
|
929
|
+
const result = await post("/setup/integration", { client: item.client, action: "install", token: preview.token });
|
|
930
|
+
setMsg("agents-msg", "ok", item.label + ": hook written. Backup: " + (result.backup || "new file") + (result.after_install ? ". " + result.after_install : ""));
|
|
931
|
+
await loadAgents();
|
|
932
|
+
} catch (error) {
|
|
933
|
+
setMsg("agents-msg", "error", error.message);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
889
936
|
} catch (error) {
|
|
890
937
|
setMsg("agents-msg", "error", error.message);
|
|
891
938
|
}
|
|
892
939
|
});
|
|
940
|
+
row.querySelector(".cancel").addEventListener("click", () => { plan.hidden = true; button.hidden = false; });
|
|
893
941
|
list.appendChild(row);
|
|
894
942
|
}
|
|
895
943
|
} catch (error) {
|
|
@@ -897,14 +945,26 @@ $("btn-save-hook-mode").addEventListener("click", async () => {
|
|
|
897
945
|
}
|
|
898
946
|
}
|
|
899
947
|
loadAgents();
|
|
900
|
-
$("btn-
|
|
901
|
-
|
|
948
|
+
$("btn-save-doc-mode").addEventListener("click", async () => {
|
|
949
|
+
try {
|
|
950
|
+
await post("/setup/integration", { action: "doc_mode", mode: $("sel-doc-mode").value });
|
|
951
|
+
setMsg("agents-msg", "ok", "Documentation setting saved; it applies on the next edit.");
|
|
952
|
+
} catch (error) {
|
|
953
|
+
setMsg("agents-msg", "error", error.message);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
$("btn-save-duplicate-mode").addEventListener("click", async () => {
|
|
957
|
+
try {
|
|
958
|
+
await post("/setup/integration", { action: "duplicate_mode", mode: $("sel-duplicate-mode").value });
|
|
959
|
+
setMsg("agents-msg", "ok", "Duplicate setting saved; it applies on the next edit.");
|
|
960
|
+
} catch (error) {
|
|
961
|
+
setMsg("agents-msg", "error", error.message);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
$("btn-save-hook-mode").addEventListener("click", async () => {
|
|
902
965
|
try {
|
|
903
|
-
|
|
904
|
-
setMsg("agents-msg", "ok", "Hook
|
|
905
|
-
agentsPlan = null;
|
|
906
|
-
$("agents-preview").hidden = true;
|
|
907
|
-
$("btn-agents-confirm").hidden = true;
|
|
966
|
+
await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });
|
|
967
|
+
setMsg("agents-msg", "ok", "Hook behavior saved; it applies on the next tool call.");
|
|
908
968
|
await loadAgents();
|
|
909
969
|
} catch (error) {
|
|
910
970
|
setMsg("agents-msg", "error", error.message);
|
|
@@ -946,8 +1006,7 @@ function providerCard(name, info) {
|
|
|
946
1006
|
try {
|
|
947
1007
|
const data = await post("/setup/providers", { provider: name, action, ...(action === "save" ? { api_key: input.value.trim() } : {}) });
|
|
948
1008
|
input.value = "";
|
|
949
|
-
await loadProviders();
|
|
950
|
-
setMsg(name + "-msg", "ok", action === "save" ? `Key accepted by the provider (${data.validated_results} result(s) in the test) and protected on this Windows machine.` : "Key removed; free tier active.");
|
|
1009
|
+
await loadProviders(action === "save" ? `Key accepted by the provider (${data.validated_results} result(s) in the test) and protected on this Windows machine.` : "Key removed; free tier active.");
|
|
951
1010
|
} catch (error) {
|
|
952
1011
|
setMsg(msg.id, "error", error.message);
|
|
953
1012
|
button.disabled = false;
|
|
@@ -966,13 +1025,27 @@ $("btn-save-contact").addEventListener("click", async () => {
|
|
|
966
1025
|
}
|
|
967
1026
|
});
|
|
968
1027
|
|
|
969
|
-
|
|
970
|
-
|
|
1028
|
+
let providers = {};
|
|
1029
|
+
function showProvider(message) {
|
|
1030
|
+
const name = $("provider-select").value;
|
|
1031
|
+
$("provider-panel").replaceChildren(...(providers[name] ? [providerCard(name, providers[name])] : []));
|
|
1032
|
+
if (message) setMsg(name + "-msg", "ok", message);
|
|
1033
|
+
}
|
|
1034
|
+
$("provider-select").addEventListener("change", () => showProvider());
|
|
1035
|
+
|
|
1036
|
+
async function loadProviders(message) {
|
|
1037
|
+
const list = $("provider-panel");
|
|
971
1038
|
try {
|
|
972
1039
|
const resp = await fetch("/setup/providers");
|
|
973
1040
|
const data = await resp.json();
|
|
974
1041
|
if (!resp.ok || data.error) throw new Error(data.error);
|
|
975
|
-
|
|
1042
|
+
providers = data.providers;
|
|
1043
|
+
const select = $("provider-select");
|
|
1044
|
+
const chosen = select.value;
|
|
1045
|
+
select.replaceChildren(...Object.entries(providers).map(([name, info]) =>
|
|
1046
|
+
new Option(info.label + " · " + ((providerLabels[info.status] || [null, info.status])[1]), name)));
|
|
1047
|
+
if (chosen && providers[chosen]) select.value = chosen;
|
|
1048
|
+
showProvider(message);
|
|
976
1049
|
if (document.activeElement !== $("contact-input")) $("contact-input").value = data.contact;
|
|
977
1050
|
$("contact-hint").textContent = "Sent as: " + data.user_agent;
|
|
978
1051
|
} catch (error) {
|