@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.
@@ -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, following config hook_mode (redirect | advise | off)."""
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 = 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)\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
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,}")
@@ -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"{label}: {exc}; trying again.", flush=True)
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
- raise RuntimeError(f"{' '.join(map(str, args[:3]))} failed (exit {process.returncode}): "
183
- + "\n".join(tail)[-1500:])
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
- for name, ready in _check().items():
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(_check().values()) else 1
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.1",
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
- CLIENT = None
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") == "hook_mode":
188
- if body.get("mode") not in config.HOOK_MODES:
189
- raise SetupError("Invalid mode: use redirect, advise or off.")
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(hook_mode=body["mode"]))
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 id="agents-list"></div>
560
- <pre class="hint" id="agents-preview" hidden></pre>
561
- <div class="actions"><button type="button" id="btn-agents-confirm" hidden>Confirm and write</button></div>
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 id="providers-list"></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
- agentsPlan = { client: item.client, ...(await post("/setup/integration", { client: item.client, action: "preview" })) };
885
- $("agents-preview").textContent = item.file + "\\n" + JSON.stringify(agentsPlan.change, null, 2);
886
- $("agents-preview").hidden = false;
887
- $("btn-agents-confirm").hidden = false;
888
- setMsg("agents-msg", "", "Review the entry above; the file is backed up before writing.");
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-agents-confirm").addEventListener("click", async () => {
901
- if (!agentsPlan) return;
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
- const result = await post("/setup/integration", { client: agentsPlan.client, action: "install", token: agentsPlan.token });
904
- setMsg("agents-msg", "ok", "Hook written. Backup: " + (result.backup || "new file") + (result.after_install ? ". " + result.after_install : ""));
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
- async function loadProviders() {
970
- const list = $("providers-list");
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
- list.replaceChildren(...Object.entries(data.providers).map(([name, info]) => providerCard(name, info)));
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) {