@allansantos-dev/smart-tool 0.9.1 → 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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 - beta
4
+
5
+ - Installer reported success after a broken Camoufox download: camoufox 0.5.6 prints the error of `fetch` and exits 0.
6
+ The installer now catches that error and fails the step (retried 3 times), every request of the download has a
7
+ 60 s read timeout (a stalled download used to wait until the 3-minute idle limit), and the final check requires the
8
+ Camoufox and Chromium executables on disk, so an incomplete browser can never end in "Smart Tool ready".
9
+ - Setup screen: text, model and adapter fields were unstyled boxes (inputs without a type), selects and model fields
10
+ now look like comboboxes; the hook change opens inside the client's card in a scrollable block with Confirm and
11
+ Cancel instead of overflowing the page; "Save behavior" works without registering the MCP first; web search keys
12
+ are set one provider at a time from a provider selector instead of a stack of key fields.
13
+ - Product page: the display font was a family that Google Fonts does not serve (titles fell back to Arial Narrow);
14
+ it now uses Big Shoulders Display. The page shows the current version (a test keeps it in sync with version.py),
15
+ links to the release notes and says what the installer does on failures and older installations. A failed Pages
16
+ deploy is retried once, the run fails unless the live page shows the version, and every release republishes it.
17
+
3
18
  ## 0.9.1 - beta
4
19
 
5
20
  - Installer on a regular Windows code page (cp1252): the steps' output was garbled or crashed the step; every child
@@ -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.2",
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/setup_ui.py CHANGED
@@ -397,9 +397,18 @@ _PAGE_TEMPLATE = """<!doctype html>
397
397
  .pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
398
398
  label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 4px; }
399
399
  .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] {
400
+ select, input:not([type]), input[type=text], input[type=url], input[type=number], input[type=password] {
401
401
  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%; }
402
+ background-color: var(--bg); color: var(--ink); font: .84rem var(--mono); min-width: 0; max-width: 100%; }
403
+ select, input[list] { appearance: none; padding-right: 34px; cursor: pointer;
404
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
405
+ background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%; background-size: 5px 5px; background-repeat: no-repeat; }
406
+ input[list] { cursor: text; }
407
+ input[list]::-webkit-calendar-picker-indicator { opacity: 0; cursor: pointer; }
408
+ select:hover, input[list]:hover { border-color: var(--muted); }
409
+ .code-preview { display: block; margin: 10px 0 0; padding: 12px 14px; max-height: 320px; overflow: auto; white-space: pre;
410
+ font: .78rem/1.5 var(--mono); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
411
+ .plan { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
403
412
  input[type=password] { font-family: var(--body); }
404
413
  input::placeholder { color: var(--idle); }
405
414
  select:focus-visible, input:focus-visible, button:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
@@ -556,10 +565,8 @@ _PAGE_TEMPLATE = """<!doctype html>
556
565
  </select>
557
566
  <span class="hint" id="hook-mode-hint">Applies to every client on the next tool call; nothing is reinstalled.</span></div>
558
567
  <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>
562
568
  <div class="msg" id="agents-msg" role="status"></div>
569
+ <div id="agents-list"></div>
563
570
  </section>
564
571
 
565
572
  <section id="providers-section">
@@ -570,7 +577,10 @@ _PAGE_TEMPLATE = """<!doctype html>
570
577
  <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
578
  <div class="actions"><button type="button" id="btn-save-contact">Save contact</button></div>
572
579
  <div class="msg" id="contact-msg" role="status"></div>
573
- <div id="providers-list"></div>
580
+ <div class="field" style="margin-top:18px"><label for="provider-select">Provider</label>
581
+ <select id="provider-select"></select>
582
+ <span class="hint">Pick a provider to see its free tier and set or remove your key for it.</span></div>
583
+ <div id="provider-panel"></div>
574
584
  </section>
575
585
  </div>
576
586
  </div>
@@ -643,8 +653,10 @@ function renderGatewayFields() {
643
653
  for (const field of (adapter || { fields: [] }).fields) {
644
654
  const wrap = document.createElement("div");
645
655
  wrap.className = "field";
646
- wrap.innerHTML = `<label></label><input autocomplete="off" spellcheck="false"><span class="hint"></span>`;
656
+ wrap.innerHTML = `<label></label><input type="text" autocomplete="off" spellcheck="false"><span class="hint"></span>`;
647
657
  const input = wrap.querySelector("input");
658
+ input.id = "gateway-field-" + field.name;
659
+ wrap.querySelector("label").htmlFor = input.id;
648
660
  wrap.querySelector("label").textContent = field.label + (field.required ? "" : " (optional)");
649
661
  input.dataset.field = field.name;
650
662
  input.dataset.secret = field.secret ? "1" : "";
@@ -831,7 +843,6 @@ $("btn-save-local-models").addEventListener("click", async () => {
831
843
  }
832
844
  });
833
845
 
834
- let agentsPlan = null;
835
846
  async function loadAgents() {
836
847
  try {
837
848
  const resp = await fetch("/setup/integration");
@@ -847,7 +858,9 @@ async function loadAgents() {
847
858
  const mcp = item.mcp_url ? "MCP registered: " + item.mcp_url : "smart-tool MCP not registered in this client";
848
859
  const hook = item.hook_error ? "Hook unreadable: " + item.hook_error : item.hook ? "Hook installed and up to date" : "Hook missing or outdated";
849
860
  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>`;
861
+ <div class="actions"><button type="button" class="register">Register MCP</button><button type="button" class="hook">Install or update hook</button></div>
862
+ <div class="plan" hidden><span class="hint plan-file"></span><pre class="code-preview"></pre>
863
+ <div class="actions"><button type="button" class="confirm">Confirm and write</button><button type="button" class="quiet cancel">Cancel</button></div></div>`;
851
864
  row.querySelector("strong").textContent = item.label;
852
865
  row.querySelector(".state").textContent = hook;
853
866
  row.querySelector(".mcp").textContent = mcp;
@@ -862,34 +875,37 @@ async function loadAgents() {
862
875
  const result = await post("/setup/integration", { client: item.client, action: "register_mcp" });
863
876
  setMsg("agents-msg", "ok", "MCP registered with: " + result.command + ". Reconnect open sessions (/mcp).");
864
877
  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
878
  } catch (error) {
875
879
  setMsg("agents-msg", "error", error.message);
876
880
  register.disabled = false;
877
881
  }
878
882
  });
879
883
  const button = row.querySelector(".hook");
884
+ const plan = row.querySelector(".plan");
880
885
  button.hidden = Boolean(item.hook);
881
886
  button.addEventListener("click", async () => {
882
887
  setMsg("agents-msg", "", "Preparing the change…");
883
888
  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.");
889
+ const preview = await post("/setup/integration", { client: item.client, action: "preview" });
890
+ row.querySelector(".plan-file").textContent = "Entry added to " + preview.file + " (the file is backed up first):";
891
+ row.querySelector(".code-preview").textContent = JSON.stringify(preview.change, null, 2);
892
+ plan.hidden = false;
893
+ button.hidden = true;
894
+ setMsg("agents-msg", "", "");
895
+ row.querySelector(".confirm").onclick = async () => {
896
+ try {
897
+ const result = await post("/setup/integration", { client: item.client, action: "install", token: preview.token });
898
+ setMsg("agents-msg", "ok", item.label + ": hook written. Backup: " + (result.backup || "new file") + (result.after_install ? ". " + result.after_install : ""));
899
+ await loadAgents();
900
+ } catch (error) {
901
+ setMsg("agents-msg", "error", error.message);
902
+ }
903
+ };
889
904
  } catch (error) {
890
905
  setMsg("agents-msg", "error", error.message);
891
906
  }
892
907
  });
908
+ row.querySelector(".cancel").addEventListener("click", () => { plan.hidden = true; button.hidden = false; });
893
909
  list.appendChild(row);
894
910
  }
895
911
  } catch (error) {
@@ -897,14 +913,10 @@ $("btn-save-hook-mode").addEventListener("click", async () => {
897
913
  }
898
914
  }
899
915
  loadAgents();
900
- $("btn-agents-confirm").addEventListener("click", async () => {
901
- if (!agentsPlan) return;
916
+ $("btn-save-hook-mode").addEventListener("click", async () => {
902
917
  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;
918
+ await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });
919
+ setMsg("agents-msg", "ok", "Hook behavior saved; it applies on the next tool call.");
908
920
  await loadAgents();
909
921
  } catch (error) {
910
922
  setMsg("agents-msg", "error", error.message);
@@ -946,8 +958,7 @@ function providerCard(name, info) {
946
958
  try {
947
959
  const data = await post("/setup/providers", { provider: name, action, ...(action === "save" ? { api_key: input.value.trim() } : {}) });
948
960
  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.");
961
+ 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
962
  } catch (error) {
952
963
  setMsg(msg.id, "error", error.message);
953
964
  button.disabled = false;
@@ -966,13 +977,27 @@ $("btn-save-contact").addEventListener("click", async () => {
966
977
  }
967
978
  });
968
979
 
969
- async function loadProviders() {
970
- const list = $("providers-list");
980
+ let providers = {};
981
+ function showProvider(message) {
982
+ const name = $("provider-select").value;
983
+ $("provider-panel").replaceChildren(...(providers[name] ? [providerCard(name, providers[name])] : []));
984
+ if (message) setMsg(name + "-msg", "ok", message);
985
+ }
986
+ $("provider-select").addEventListener("change", () => showProvider());
987
+
988
+ async function loadProviders(message) {
989
+ const list = $("provider-panel");
971
990
  try {
972
991
  const resp = await fetch("/setup/providers");
973
992
  const data = await resp.json();
974
993
  if (!resp.ok || data.error) throw new Error(data.error);
975
- list.replaceChildren(...Object.entries(data.providers).map(([name, info]) => providerCard(name, info)));
994
+ providers = data.providers;
995
+ const select = $("provider-select");
996
+ const chosen = select.value;
997
+ select.replaceChildren(...Object.entries(providers).map(([name, info]) =>
998
+ new Option(info.label + " · " + ((providerLabels[info.status] || [null, info.status])[1]), name)));
999
+ if (chosen && providers[chosen]) select.value = chosen;
1000
+ showProvider(message);
976
1001
  if (document.activeElement !== $("contact-input")) $("contact-input").value = data.contact;
977
1002
  $("contact-hint").textContent = "Sent as: " + data.user_agent;
978
1003
  } catch (error) {
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.1"
4
+ VERSION = "0.9.2"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
@@ -1,13 +1,21 @@
1
- """`camoufox fetch` for the installer: a stalled download fails after 60 s instead of hanging, and progress is printed
2
- as plain lines (the rich bar stays invisible when the output is a pipe)."""
3
- import socket
1
+ """`camoufox fetch` for the installer. camoufox 0.5.6 prints a failed download and still exits 0, so the error is
2
+ caught here and turned into a failing exit, and the browser executable must exist afterwards. Its downloads have no
3
+ timeout (requests passes None, which also overrides socket.setdefaulttimeout), so every request gets one here: a
4
+ stalled download fails after 60 s instead of hanging. Progress is printed as plain lines (the rich bar stays
5
+ invisible in a pipe)."""
6
+ import sys
7
+ from pathlib import Path
4
8
 
9
+ import camoufox.__main__ as camoufox_cli
10
+ import requests
5
11
  from camoufox import pkgman
6
- from camoufox.__main__ import cli
7
12
 
8
- socket.setdefaulttimeout(60)
13
+ TIMEOUT_S = (30, 60)
9
14
  REPORT_EVERY_MB = 50
10
15
  _webdl = pkgman.webdl
16
+ _update = camoufox_cli.CamoufoxUpdate.update
17
+ _get = requests.get
18
+ failures = []
11
19
 
12
20
 
13
21
  def _reporting_webdl(url, desc=None, buffer=None, bar=True, progress_callback=None):
@@ -22,7 +30,40 @@ def _reporting_webdl(url, desc=None, buffer=None, bar=True, progress_callback=No
22
30
  return _webdl(url, desc=desc, buffer=buffer, bar=False, progress_callback=progress_callback or report)
23
31
 
24
32
 
25
- pkgman.webdl = _reporting_webdl
33
+ def _get_with_timeout(*args, **kwargs):
34
+ kwargs.setdefault("timeout", TIMEOUT_S)
35
+ return _get(*args, **kwargs)
36
+
37
+
38
+ def _recording_update(self, *args, **kwargs):
39
+ try:
40
+ return _update(self, *args, **kwargs)
41
+ except Exception as exc:
42
+ failures.append(exc)
43
+ raise
44
+
45
+
46
+ def installed_executable():
47
+ """The browser executable, without the download that launch_path() starts when it is missing."""
48
+ executable = Path(pkgman.camoufox_path(download_if_missing=False)) / pkgman.LAUNCH_FILE[pkgman.OS_NAME]
49
+ if not executable.is_file():
50
+ raise FileNotFoundError(executable)
51
+ return executable
52
+
53
+
54
+ def main():
55
+ requests.get = _get_with_timeout
56
+ pkgman.webdl = _reporting_webdl
57
+ camoufox_cli.CamoufoxUpdate.update = _recording_update
58
+ camoufox_cli.cli.main(["fetch"], standalone_mode=False)
59
+ if failures:
60
+ sys.exit(f"Camoufox download failed: {failures[-1]}")
61
+ try:
62
+ executable = installed_executable()
63
+ except Exception as exc:
64
+ sys.exit(f"Camoufox is not installed after the download: {exc}")
65
+ print(f"Camoufox {pkgman.installed_verstr()} ready: {executable}", flush=True)
66
+
26
67
 
27
68
  if __name__ == "__main__":
28
- cli(["fetch"])
69
+ main()