witbitz-code 1.3.4__tar.gz → 1.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {witbitz_code-1.3.4/src/witbitz_code.egg-info → witbitz_code-1.3.6}/PKG-INFO +6 -3
  2. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/README.md +4 -2
  3. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/pyproject.toml +3 -3
  4. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/cli.py +7 -1
  6. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +19 -10
  7. witbitz_code-1.3.6/src/witbitz_code/tools_catalog.json +219 -0
  8. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/tools_probe.py +8 -1
  9. witbitz_code-1.3.6/src/witbitz_code/toolsetup.py +179 -0
  10. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/winagent.py +5 -2
  11. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/wizard.py +64 -3
  12. {witbitz_code-1.3.4 → witbitz_code-1.3.6/src/witbitz_code.egg-info}/PKG-INFO +6 -3
  13. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
  14. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code.egg-info/requires.txt +1 -0
  15. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_setup.py +3 -1
  16. witbitz_code-1.3.6/tests/test_toolsetup.py +205 -0
  17. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_winagent.py +10 -0
  18. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/LICENSE +0 -0
  19. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/setup.cfg +0 -0
  20. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/__main__.py +0 -0
  21. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/_js.py +0 -0
  22. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/account.py +0 -0
  23. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/asks.py +0 -0
  24. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/attachments.py +0 -0
  25. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/auto.py +0 -0
  26. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/auto_runner.py +0 -0
  27. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/codekeys.py +0 -0
  28. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/codetools.py +0 -0
  29. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/compress.py +0 -0
  30. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/computers.py +0 -0
  31. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/confidential.py +0 -0
  32. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/confidential_models.json +0 -0
  33. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/connector.py +0 -0
  34. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/devicelink.py +0 -0
  35. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/folders.py +0 -0
  36. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/gateway.py +0 -0
  37. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/keys.py +0 -0
  38. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/link.py +0 -0
  39. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/models.py +0 -0
  40. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/net.py +0 -0
  41. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/offered_models.json +0 -0
  42. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  43. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  44. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  45. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/outputs.py +0 -0
  46. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/pair.py +0 -0
  47. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/pairings.py +0 -0
  48. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/payers.py +0 -0
  49. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/plugins.py +0 -0
  50. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/policy.py +0 -0
  51. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/ports.py +0 -0
  52. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/qr.py +0 -0
  53. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/receipt.py +0 -0
  54. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/relay.py +0 -0
  55. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/seen.py +0 -0
  56. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/tinfoil_read.py +0 -0
  57. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/uninstall.py +0 -0
  58. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code/winconsole.py +0 -0
  59. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  60. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  61. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/src/witbitz_code.egg-info/top_level.txt +0 -0
  62. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_asks.py +0 -0
  63. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_attachments.py +0 -0
  64. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_auto.py +0 -0
  65. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_cli.py +0 -0
  66. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_codekeys.py +0 -0
  67. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_codetools.py +0 -0
  68. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_confidential.py +0 -0
  69. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_connector_e2e.py +0 -0
  70. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_devicelink.py +0 -0
  71. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_files_and_compress.py +0 -0
  72. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_folders.py +0 -0
  73. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_outputs.py +0 -0
  74. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_pair_flows.py +0 -0
  75. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_plugins.py +0 -0
  76. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_policy.py +0 -0
  77. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_ports.py +0 -0
  78. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_registry.py +0 -0
  79. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_relay_codec.py +0 -0
  80. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_tools_probe.py +0 -0
  81. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_uninstall.py +0 -0
  82. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_winconsole.py +0 -0
  83. {witbitz_code-1.3.4 → witbitz_code-1.3.6}/tests/test_windows_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Dist: segno>=1.5
21
21
  Requires-Dist: tinfoil<0.15,>=0.14
22
22
  Provides-Extra: test
23
23
  Requires-Dist: pytest>=8; extra == "test"
24
+ Requires-Dist: pyflakes>=3; extra == "test"
24
25
  Dynamic: license-file
25
26
 
26
27
  # witbitz-code
@@ -48,9 +49,11 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
48
49
  witbitz-code setup
49
50
  ```
50
51
 
51
- `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
+ `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
53
  this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), which gives the computer **its
53
- own keys from your Spaces account**, then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
+ own keys from your Spaces account**, offers to install the tools the agent uses for audio, video, PDFs and scans (ffmpeg,
55
+ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it shows what it will run and asks once), then
56
+ starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
57
  stops it); next time, `witbitz-code serve` is enough.
55
58
 
56
59
  **No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
@@ -23,9 +23,11 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
23
23
  witbitz-code setup
24
24
  ```
25
25
 
26
- `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
26
+ `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
27
27
  this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), which gives the computer **its
28
- own keys from your Spaces account**, then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
28
+ own keys from your Spaces account**, offers to install the tools the agent uses for audio, video, PDFs and scans (ffmpeg,
29
+ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it shows what it will run and asks once), then
30
+ starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
29
31
  stops it); next time, `witbitz-code serve` is enough.
30
32
 
31
33
  **No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.3.4"
7
+ version = "1.3.6"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,7 +29,7 @@ dependencies = [
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
32
- test = ["pytest>=8"]
32
+ test = ["pytest>=8", "pyflakes>=3"]
33
33
 
34
34
  [project.urls]
35
35
  Documentation = "https://witbitz.chat/docs/opencode.md"
@@ -43,7 +43,7 @@ where = ["src"]
43
43
 
44
44
  # Our OpenCode plugins, installed by `serve` (plugins.py) — copies of tools/opencode-plugins, kept current by embed.mjs
45
45
  [tool.setuptools.package-data]
46
- witbitz_code = ["opencode_plugins/*", "confidential_models.json", "offered_models.json"]
46
+ witbitz_code = ["opencode_plugins/*", "confidential_models.json", "offered_models.json", "tools_catalog.json"]
47
47
 
48
48
  [tool.pytest.ini_options]
49
49
  testpaths = ["tests"]
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.3.4"
7
+ __version__ = "1.3.6"
@@ -26,7 +26,7 @@ import httpx
26
26
  from . import __version__
27
27
  from . import pair as pair_cmd
28
28
  from . import pairings as pp
29
- from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, winagent, winconsole, wizard
29
+ from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winconsole, wizard
30
30
  from ._js import to_number, truthy
31
31
  from .connector import pairings_for_port, start_connector
32
32
  from .payers import SessionPayers
@@ -298,11 +298,17 @@ async def setup(args: list[str]) -> int:
298
298
  return {"state": st["why"]}
299
299
  return {"state": "unknown", "why": str(st.get("why") or "no answer")}
300
300
 
301
+ def refresh_path() -> None: # Windows: a tool winget just installed is on the registry's PATH, not yet on ours
302
+ reg = toolsetup.registry_path()
303
+ if reg:
304
+ os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
305
+
301
306
  d = wizard.Deps(
302
307
  say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
303
308
  port=port, port_explicit="--port" in args,
304
309
  pair=lambda p: pair_cmd.main(["--port", str(p)]),
305
310
  keys_state=keys_state,
311
+ run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
306
312
  is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
307
313
  )
308
314
  try:
@@ -719,26 +719,35 @@ export function parseSearchAnswer (data) { // == agent/tinfoilWebSearch.mjs pars
719
719
  }
720
720
  return { text, citations }
721
721
  }
722
- async function webSearch (a, { fetchImpl = fetch, env = process.env } = {}) {
723
- const q = String((a && a.query) || '').replace(/\s+/g, ' ').trim().slice(0, 2000)
724
- if (!q) fail('query is required')
725
- const apiKey = env.TINFOIL_API_KEY
726
- if (!apiKey) fail('web_search needs a Tinfoil key: TINFOIL_API_KEY is not set for this OpenCode (on a laptop it comes from ~/.opencode-server.env; a cloud computer gets it from its Sentinel)')
727
- const url = env.WITBITZ_SEARCH_URL || `https://${SEARCH_HOST}${SEARCH_PATH}`
728
- const body = {
722
+ /** The one Tinfoil web-search request, for a laptop (sent with the key) and for a cloud computer's Sentinel (which builds it
723
+ * from the query alone, so the key it holds cannot be spent on any other call — infra/cloud-computer/sentinel). */
724
+ export function searchRequest (query, { piiCheck = true } = {}) {
725
+ return {
729
726
  model: SEARCH_MODEL,
730
727
  messages: [
731
728
  { role: 'system', content: 'You are a web research assistant. Search the web and report what you find, briefly and factually, with dates. State plainly when sources disagree or when something has not happened yet. Do not speculate beyond what the sources say.' },
732
- { role: 'user', content: q },
729
+ { role: 'user', content: query },
733
730
  ],
734
731
  max_tokens: SEARCH_MAX_TOKENS,
735
732
  reasoning_effort: SEARCH_REASONING_EFFORT,
736
733
  web_search_options: {},
737
- ...(a && a.pii_check === false ? {} : { pii_check_options: {} }), // a query carrying personal data is refused before it leaves the enclave for the index
734
+ ...(piiCheck === false ? {} : { pii_check_options: {} }), // a query carrying personal data is refused before it leaves the enclave for the index
738
735
  }
736
+ }
737
+
738
+ async function webSearch (a, { fetchImpl = fetch, env = process.env } = {}) {
739
+ const q = String((a && a.query) || '').replace(/\s+/g, ' ').trim().slice(0, 2000)
740
+ if (!q) fail('query is required')
741
+ const piiCheck = !(a && a.pii_check === false)
742
+ // A cloud computer holds no key: the query goes to its Credential Sentinel, which builds this same request with the key.
743
+ const viaSentinel = env.WITBITZ_SEARCH_SENTINEL
744
+ const apiKey = env.TINFOIL_API_KEY
745
+ if (!viaSentinel && !apiKey) fail('web_search needs a Tinfoil key: TINFOIL_API_KEY is not set for this OpenCode (on a laptop it comes from ~/.opencode-server.env; a cloud computer gets it from its Sentinel)')
746
+ const url = viaSentinel || env.WITBITZ_SEARCH_URL || `https://${SEARCH_HOST}${SEARCH_PATH}`
747
+ const body = viaSentinel ? { query: q, pii_check: piiCheck } : searchRequest(q, { piiCheck })
739
748
  let data
740
749
  try {
741
- const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) })
750
+ const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(viaSentinel ? {} : { authorization: `Bearer ${apiKey}` }) }, body: JSON.stringify(body), signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) })
742
751
  if (!res.ok) fail(`the search endpoint answered ${res.status}`)
743
752
  data = await res.json()
744
753
  } catch (e) { fail(`could not search the web (${String((e && e.message) || e).slice(0, 160)})`) }
@@ -0,0 +1,219 @@
1
+ {
2
+ "tools": [
3
+ {
4
+ "id": "git",
5
+ "label": "Git",
6
+ "bins": [
7
+ "git"
8
+ ],
9
+ "pkg": {
10
+ "brew": "git",
11
+ "apt": "git",
12
+ "dnf": "git",
13
+ "pacman": "git",
14
+ "winget": "Git.Git"
15
+ },
16
+ "auto": [],
17
+ "note": ""
18
+ },
19
+ {
20
+ "id": "ripgrep",
21
+ "label": "ripgrep",
22
+ "bins": [
23
+ "rg"
24
+ ],
25
+ "pkg": {
26
+ "brew": "ripgrep",
27
+ "apt": "ripgrep",
28
+ "dnf": "ripgrep",
29
+ "pacman": "ripgrep",
30
+ "winget": "BurntSushi.ripgrep.MSVC"
31
+ },
32
+ "auto": [],
33
+ "note": ""
34
+ },
35
+ {
36
+ "id": "jq",
37
+ "label": "jq",
38
+ "bins": [
39
+ "jq"
40
+ ],
41
+ "pkg": {
42
+ "brew": "jq",
43
+ "apt": "jq",
44
+ "dnf": "jq",
45
+ "pacman": "jq",
46
+ "winget": "jqlang.jq"
47
+ },
48
+ "auto": [],
49
+ "note": ""
50
+ },
51
+ {
52
+ "id": "python",
53
+ "label": "Python 3",
54
+ "bins": [
55
+ "python3",
56
+ "python"
57
+ ],
58
+ "pkg": {
59
+ "brew": "python",
60
+ "apt": "python3",
61
+ "dnf": "python3",
62
+ "pacman": "python",
63
+ "winget": "Python.Python.3.12"
64
+ },
65
+ "auto": [],
66
+ "note": ""
67
+ },
68
+ {
69
+ "id": "uv",
70
+ "label": "uv",
71
+ "bins": [
72
+ "uv"
73
+ ],
74
+ "pkg": {
75
+ "brew": "uv",
76
+ "apt": "",
77
+ "dnf": "",
78
+ "pacman": "uv",
79
+ "winget": "astral-sh.uv"
80
+ },
81
+ "auto": [],
82
+ "note": "where there is no package: the official installer from astral.sh"
83
+ },
84
+ {
85
+ "id": "ffmpeg",
86
+ "label": "ffmpeg",
87
+ "bins": [
88
+ "ffmpeg"
89
+ ],
90
+ "pkg": {
91
+ "brew": "ffmpeg",
92
+ "apt": "ffmpeg",
93
+ "dnf": "ffmpeg",
94
+ "pacman": "ffmpeg",
95
+ "winget": "Gyan.FFmpeg"
96
+ },
97
+ "auto": [],
98
+ "note": ""
99
+ },
100
+ {
101
+ "id": "whisper",
102
+ "label": "Whisper",
103
+ "bins": [
104
+ "whisper-cli",
105
+ "whisper-cpp",
106
+ "whisper"
107
+ ],
108
+ "pkg": {
109
+ "brew": "whisper-cpp",
110
+ "apt": "",
111
+ "dnf": "",
112
+ "pacman": "",
113
+ "winget": ""
114
+ },
115
+ "auto": [
116
+ "linux",
117
+ "win32"
118
+ ],
119
+ "note": "where there is no package: `uv tool install openai-whisper`; either way it needs a model downloaded before first use — say how big it is before fetching it"
120
+ },
121
+ {
122
+ "id": "poppler",
123
+ "label": "Poppler",
124
+ "bins": [
125
+ "pdftotext"
126
+ ],
127
+ "pkg": {
128
+ "brew": "poppler",
129
+ "apt": "poppler-utils",
130
+ "dnf": "poppler-utils",
131
+ "pacman": "poppler",
132
+ "winget": ""
133
+ },
134
+ "auto": [],
135
+ "note": ""
136
+ },
137
+ {
138
+ "id": "qpdf",
139
+ "label": "qpdf",
140
+ "bins": [
141
+ "qpdf"
142
+ ],
143
+ "pkg": {
144
+ "brew": "qpdf",
145
+ "apt": "qpdf",
146
+ "dnf": "qpdf",
147
+ "pacman": "qpdf",
148
+ "winget": "QPDF.QPDF"
149
+ },
150
+ "auto": [],
151
+ "note": ""
152
+ },
153
+ {
154
+ "id": "tesseract",
155
+ "label": "Tesseract",
156
+ "bins": [
157
+ "tesseract"
158
+ ],
159
+ "pkg": {
160
+ "brew": "tesseract",
161
+ "apt": "tesseract-ocr",
162
+ "dnf": "tesseract",
163
+ "pacman": "tesseract",
164
+ "winget": "UB-Mannheim.TesseractOCR"
165
+ },
166
+ "auto": [],
167
+ "note": ""
168
+ },
169
+ {
170
+ "id": "pandoc",
171
+ "label": "Pandoc",
172
+ "bins": [
173
+ "pandoc"
174
+ ],
175
+ "pkg": {
176
+ "brew": "pandoc",
177
+ "apt": "pandoc",
178
+ "dnf": "pandoc",
179
+ "pacman": "pandoc",
180
+ "winget": "JohnMacFarlane.Pandoc"
181
+ },
182
+ "auto": [],
183
+ "note": ""
184
+ },
185
+ {
186
+ "id": "imagemagick",
187
+ "label": "ImageMagick",
188
+ "bins": [
189
+ "magick",
190
+ "convert"
191
+ ],
192
+ "pkg": {
193
+ "brew": "imagemagick",
194
+ "apt": "imagemagick",
195
+ "dnf": "ImageMagick",
196
+ "pacman": "imagemagick",
197
+ "winget": "ImageMagick.ImageMagick"
198
+ },
199
+ "auto": [],
200
+ "note": ""
201
+ }
202
+ ],
203
+ "adminLines": {
204
+ "apt": "sudo apt-get update && sudo apt-get install -y {pkgs}",
205
+ "dnf": "sudo dnf install -y {pkgs}",
206
+ "pacman": "sudo pacman -S --needed --noconfirm {pkgs}",
207
+ "zypper": "sudo zypper install -y {pkgs}",
208
+ "apk": "sudo apk add {pkgs}"
209
+ },
210
+ "windowsReleases": {
211
+ "poppler": {
212
+ "url": "https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip",
213
+ "sha256": "7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0",
214
+ "mb": 42,
215
+ "inner": "poppler-26.09.0/Library/bin/",
216
+ "dir": "poppler\\bin"
217
+ }
218
+ }
219
+ }
@@ -27,6 +27,9 @@ PACKAGE_MANAGERS: dict[str, list[tuple[str, str]]] = {
27
27
  }
28
28
  _EXTRA_POSIX = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/snap/bin", "/home/linuxbrew/.linuxbrew/bin"]
29
29
  _EXTRA_HOME = [".local/bin", ".cargo/bin", "bin"]
30
+ # Windows: under %LOCALAPPDATA%\Witbitz — what the Windows installer (code.ps1) fills and witbitz-code serve gives the agent:
31
+ # its Git (Git Bash, the agent's shell), its own Python, and what setup's Tools step puts there (Poppler). = code-tools-probe.mjs
32
+ _WIN_WITBITZ = ["bin", "git\\cmd", "git\\bin", "agent-python\\Scripts", "poppler\\bin"]
30
33
 
31
34
 
32
35
  def _is_executable(path: str) -> bool:
@@ -42,7 +45,11 @@ def search_dirs(*, env: Mapping[str, str], platform: str, home: str) -> list[str
42
45
  win = platform == "win32"
43
46
  raw = env.get("PATH") or env.get("Path") or ""
44
47
  from_path = [d for d in raw.split(";" if win else ":") if d]
45
- extra = [] if win else _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
48
+ if win:
49
+ local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
50
+ extra = [f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []
51
+ else:
52
+ extra = _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
46
53
  return list(dict.fromkeys(from_path + extra))
47
54
 
48
55
 
@@ -0,0 +1,179 @@
1
+ """witbitz-code setup's Tools step — the Python twin of tools/code-tools-install.mjs and codeTools.js installPlan.
2
+
3
+ Mechanical, in the setup terminal (the owner, 2026-10-08: "Why all this setup (or most of it can't be mechanical)?"): a
4
+ package manager's own install with the person's terminal attached (sudo can ask for the password, winget's Windows
5
+ permission prompt appears in front of them), a pinned download checked against its publisher's SHA-256, and nothing for
6
+ what fetches itself on first use. No model, no approval cards; the agent on the Code card is the fallback.
7
+ The catalog data is tools_catalog.json, written by tools/code-py-models.mjs; tests/test_toolsetup.py holds the plans equal.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import ntpath
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import zipfile
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ _CATALOG = json.loads(Path(__file__).with_name("tools_catalog.json").read_text(encoding="utf-8"))
28
+ TOOLS: list[dict] = _CATALOG["tools"]
29
+ ADMIN_LINES: dict[str, str] = _CATALOG["adminLines"]
30
+ WINDOWS_RELEASES: dict[str, dict] = _CATALOG["windowsReleases"]
31
+
32
+ WINGET_FLAGS = ["--exact", "--source", "winget", "--silent", "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity"]
33
+
34
+
35
+ def label(tool_id: str) -> str:
36
+ return next((t["label"] for t in TOOLS if t["id"] == tool_id), tool_id)
37
+
38
+
39
+ def missing_tools(report: dict | None) -> list[dict]:
40
+ """= codeTools.js missingTools: missing per the report, in catalog order, never one this OS gets by itself (`auto`)."""
41
+ if not report:
42
+ return []
43
+ os_id = (report.get("platform") or {}).get("os") or ""
44
+ return [t for t in TOOLS if report.get("tools", {}).get(t["id"]) is False and os_id not in t["auto"]]
45
+
46
+
47
+ def install_plan(ids: list[str], platform: dict) -> list[dict]:
48
+ """= codeTools.js installPlan → the steps setup runs, in order (see there)."""
49
+ os_id, pm = (platform or {}).get("os") or "", (platform or {}).get("pm") or ""
50
+ steps: list[dict] = []
51
+ batch: list[str] = []
52
+ batch_ids: list[str] = []
53
+ for t in TOOLS:
54
+ if t["id"] not in ids:
55
+ continue
56
+ if os_id in t["auto"]:
57
+ steps.append({"kind": "auto", "id": t["id"]})
58
+ continue
59
+ pkg = t["pkg"].get(pm, "") if pm else ""
60
+ if pkg and pm == "winget":
61
+ steps.append({"kind": "cmd", "ids": [t["id"]], "argv": ["winget", "install", "--id", pkg, *WINGET_FLAGS]})
62
+ elif pkg and (pm == "brew" or pm in ADMIN_LINES):
63
+ batch.append(pkg)
64
+ batch_ids.append(t["id"])
65
+ elif os_id == "win32" and t["id"] in WINDOWS_RELEASES:
66
+ steps.append({"kind": "release", "id": t["id"], **WINDOWS_RELEASES[t["id"]]})
67
+ else:
68
+ steps.append({"kind": "none", "id": t["id"], "note": t["note"]})
69
+ if batch:
70
+ steps.insert(0, {"kind": "cmd", "ids": batch_ids, "argv": ["brew", "install", *batch]} if pm == "brew"
71
+ else {"kind": "cmd", "ids": batch_ids, "admin": True, "argv": ["sh", "-c", ADMIN_LINES[pm].replace("{pkgs}", " ".join(batch))]})
72
+ return steps
73
+
74
+
75
+ def witbitz_dir(env: dict | None = None) -> str:
76
+ env = os.environ if env is None else env
77
+ return ntpath.join(env.get("LOCALAPPDATA", ""), "Witbitz")
78
+
79
+
80
+ def run_inherit(argv: list[str]) -> int:
81
+ """A command with the person's terminal attached → its exit code (-1: it could not start)."""
82
+ try:
83
+ return subprocess.run(argv, check=False).returncode # noqa: S603 — argv from installPlan, never a shell string of ours
84
+ except (OSError, ValueError):
85
+ return -1
86
+
87
+
88
+ def install_release(step: dict, *, client: httpx.Client | None = None, dest: str | None = None) -> str:
89
+ """A pinned zip → its `inner` folder's files into <witbitz_dir>/<dir>. Raises ValueError with a sentence."""
90
+ own = client is None
91
+ client = client or httpx.Client(timeout=httpx.Timeout(300, connect=20), follow_redirects=True)
92
+ try:
93
+ r = client.get(step["url"])
94
+ except httpx.HTTPError as e:
95
+ raise ValueError(f"could not download it ({str(e)[:80]})") from e
96
+ finally:
97
+ if own:
98
+ client.close()
99
+ if r.status_code != 200:
100
+ raise ValueError(f"the download answered {r.status_code}")
101
+ got = hashlib.sha256(r.content).hexdigest()
102
+ if got != step["sha256"]:
103
+ raise ValueError(f"the download is not the pinned file (sha256 {got[:12]}…) — nothing was installed")
104
+ to = Path(dest or witbitz_dir(), *step["dir"].split("\\"))
105
+ inner = step["inner"]
106
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
107
+ members = [m for m in z.infolist() if m.filename.startswith(inner) and not m.is_dir()]
108
+ if not members:
109
+ raise ValueError(f"the download does not hold {inner}")
110
+ to.mkdir(parents=True, exist_ok=True)
111
+ for m in members:
112
+ rel = m.filename[len(inner):]
113
+ if not rel or ".." in Path(rel).parts:
114
+ continue
115
+ out = to / rel
116
+ out.parent.mkdir(parents=True, exist_ok=True)
117
+ with z.open(m) as src, open(out, "wb") as dst:
118
+ shutil.copyfileobj(src, dst)
119
+ return str(to)
120
+
121
+
122
+ def run_plan(steps: list[dict], *, say: Callable[[str], Any] = print, run: Callable[[list[str]], int] = run_inherit,
123
+ release: Callable[[dict], str] = install_release) -> dict:
124
+ """Carry out the plan → {done, failed, auto, none} (tool ids). Says each step as it goes. = code-tools-install.mjs runPlan"""
125
+ out: dict = {"done": [], "failed": [], "auto": [], "none": []}
126
+ for s in steps:
127
+ if s["kind"] == "cmd":
128
+ say(f" $ {s['argv'][2] if s['argv'][0] == 'sh' else ' '.join(s['argv'])}")
129
+ code = run(s["argv"])
130
+ (out["done"] if code == 0 else out["failed"]).extend(s["ids"])
131
+ if code != 0:
132
+ say(f" ✖ {', '.join(label(i) for i in s['ids'])}: that {'could not start' if code == -1 else f'ended with {code}'}")
133
+ elif s["kind"] == "release":
134
+ say(f" Downloading {label(s['id'])} ({s['mb']} MB) from its publisher, checked against its SHA-256 …")
135
+ try:
136
+ to = release(s)
137
+ out["done"].append(s["id"])
138
+ say(f" ✓ {label(s['id'])} → {to}")
139
+ except (ValueError, OSError) as e:
140
+ out["failed"].append(s["id"])
141
+ say(f" ✖ {label(s['id'])}: {e}")
142
+ elif s["kind"] == "auto":
143
+ out["auto"].append(s["id"])
144
+ say(f" ✓ {label(s['id'])}: fetched by itself the first time it is needed — nothing to install now")
145
+ else:
146
+ out["none"].append(s["id"])
147
+ note = f" ({s['note']})" if s.get("note") else ""
148
+ say(f" – {label(s['id'])}: nothing here installs it{note} — \"Install with the agent\" in Code can try")
149
+ return out
150
+
151
+
152
+ def registry_path(reg: Any = None) -> str | None:
153
+ """Windows: PATH as the registry has it now (machine, then user) — a program winget just installed is on it, while this
154
+ process still has the PATH it started with. None elsewhere or when it cannot be read."""
155
+ if sys.platform != "win32" and reg is None:
156
+ return None
157
+ if reg is None:
158
+ import winreg as reg # noqa: PLC0415 — Windows only
159
+ parts = []
160
+ for root, key in ((reg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), (reg.HKEY_CURRENT_USER, "Environment")):
161
+ try:
162
+ with reg.OpenKey(root, key) as k:
163
+ value, _kind = reg.QueryValueEx(k, "Path")
164
+ parts.append(reg.ExpandEnvironmentStrings(value) if hasattr(reg, "ExpandEnvironmentStrings") else value)
165
+ except OSError:
166
+ continue
167
+ return ";".join(p for p in parts if p) or None
168
+
169
+
170
+ def merged_path(current: str, from_registry: str | None) -> str:
171
+ """This process's PATH with the registry's entries added (new installs), keeping what it had. = mergedPath"""
172
+ seen: set[str] = set()
173
+ parts: list[str] = []
174
+ for d in (from_registry or "").split(";") + (current or "").split(";"):
175
+ d = d.strip()
176
+ if d and d.lower() not in seen:
177
+ seen.add(d.lower())
178
+ parts.append(d)
179
+ return ";".join(parts)
@@ -81,8 +81,11 @@ def windows_agent(config_dir: Path, env: dict | None = None, platform: str | Non
81
81
  if bash:
82
82
  add["SHELL"] = bash
83
83
  py = find_agent_python(env, exists)
84
- if py:
85
- add["PATH_PREPEND"] = [py]
84
+ # Poppler from setup's Tools step (toolsetup.py: winget has no package, so a pinned release into Witbitz\poppler\bin)
85
+ poppler = ntpath.join(_witbitz_dir(env), "poppler", "bin")
86
+ prepend = [d for d in (py, poppler if exists(ntpath.join(poppler, "pdftotext.exe")) else None) if d]
87
+ if prepend:
88
+ add["PATH_PREPEND"] = prepend
86
89
  f = Path(config_dir) / INSTRUCTIONS_NAME
87
90
  instr: str | None = None
88
91
  try:
@@ -21,7 +21,7 @@ from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
24
- from . import codekeys, ports
24
+ from . import codekeys, ports, tools_probe, toolsetup
25
25
  from . import pairings as pp
26
26
  from .connector import _url_port
27
27
 
@@ -77,6 +77,10 @@ class Deps:
77
77
  port_owners: Callable[[int], list] = ports.port_owners # [{pid, cmd}] — this user's processes on a port (ports.py)
78
78
  stop_process: Callable[[int], bool] = lambda pid: __import__("witbitz_code.uninstall", fromlist=["stop_process"]).stop_process(pid)
79
79
  wsl: bool = field(default_factory=ports.on_wsl)
80
+ # setup's Tools step (toolsetup.py): what is here, carrying out the plan, and PATH after installs (Windows: the registry)
81
+ probe_tools: Callable[[], dict] = lambda: tools_probe.probe_tools()
82
+ run_tools: Callable[[list], dict] = None # type: ignore[assignment]
83
+ refresh_path: Callable[[], None] = lambda: None
80
84
  serve_here: Callable[[int], Awaitable[int]] = None # type: ignore[assignment]
81
85
  result: dict = field(default_factory=lambda: {"opencode": False, "paired": False, "keys": False, "running": ""})
82
86
 
@@ -88,7 +92,7 @@ def _yes(answer: str) -> bool:
88
92
  async def run_setup(d: Deps) -> dict:
89
93
  port = d.port
90
94
  result = d.result
91
- d.say("witbitz-code setup — four steps; anything already done is skipped.\n")
95
+ d.say("witbitz-code setup — five steps; anything already done is skipped.\n")
92
96
 
93
97
  # 1 — OpenCode
94
98
  d.say("1. OpenCode, the coding agent")
@@ -178,7 +182,12 @@ async def run_setup(d: Deps) -> dict:
178
182
  result["keys"] = True
179
183
 
180
184
  # 4 — running (this package has no background service yet: it runs in this window)
181
- d.say("\n4. Keep it running")
185
+ # 4 — the tools the agent works with (the Code card's list), installed HERE, mechanically (toolsetup.py; the owner,
186
+ # 2026-10-08: "Why all this setup (or most of it can't be mechanical)?"). Asked once; nothing runs on a no.
187
+ d.say("\n4. Tools for audio, video, PDFs and scans")
188
+ result["tools"] = await _tools_step(d)
189
+
190
+ d.say("\n5. Keep it running")
182
191
  # OpenCode's port AND the model proxy's (port + 100): an earlier witbitz-code still running holds both, and keeps answering
183
192
  # the phone with the keys it started with (the owner, 2026-10-08: `pipx uninstall` left it running — "could not start
184
193
  # the model proxy … address already in use", then "Invalid API key"). This person's own process: offer to stop it.
@@ -218,3 +227,55 @@ async def run_setup(d: Deps) -> dict:
218
227
  result["port"] = port
219
228
  result["served"] = await d.serve_here(port)
220
229
  return result
230
+
231
+
232
+ def _describe(step: dict) -> str:
233
+ if step["kind"] == "cmd":
234
+ line = step["argv"][2] if step["argv"][0] == "sh" else " ".join(step["argv"][:4])
235
+ return f" {line}" + (" (asks for your password)" if step.get("admin") else "")
236
+ if step["kind"] == "release":
237
+ return f" {toolsetup.label(step['id'])}: a {step['mb']} MB download from its publisher, checked against its SHA-256"
238
+ if step["kind"] == "auto":
239
+ return f" {toolsetup.label(step['id'])}: comes by itself the first time it is needed"
240
+ return f" {toolsetup.label(step['id'])}: nothing here installs it" + (f" ({step['note']})" if step.get("note") else "")
241
+
242
+
243
+ async def _tools_step(d: Deps) -> str:
244
+ """→ 'here' | 'installed' | 'partly' | 'skipped' | 'unavailable' | 'failed' (an install that breaks never ends setup)."""
245
+ try:
246
+ report = d.probe_tools()
247
+ except Exception: # noqa: BLE001 — a probe that fails never stops setup
248
+ d.say(" (could not check which tools are here — skipped)")
249
+ return "skipped"
250
+ missing = toolsetup.missing_tools(report)
251
+ if not missing:
252
+ d.say(" ✓ Everything the agent uses for these is here")
253
+ return "here"
254
+ plan = toolsetup.install_plan([t["id"] for t in missing], report.get("platform") or {})
255
+ d.say(f" Missing here: {', '.join(t['label'] for t in missing)}.")
256
+ if not any(st["kind"] in ("cmd", "release") for st in plan):
257
+ d.say(" Nothing on this computer can install them (no package manager found) — \"Install with the agent\" in Code can try.")
258
+ return "unavailable"
259
+ d.say(" This would run:")
260
+ for st in plan:
261
+ d.say(_describe(st))
262
+ if not _yes(d.ask(" Install them now? [Y/n] ")):
263
+ d.say(" Skipped — run witbitz-code setup again to install them, or use the card in Code.")
264
+ return "skipped"
265
+ try:
266
+ d.run_tools(plan)
267
+ d.refresh_path()
268
+ except Exception as e: # noqa: BLE001 — the tools are a convenience: setup goes on to serve
269
+ d.say(f" Could not install them ({str(e)[:160]}) — setup goes on; \"Install with the agent\" in Code can try.")
270
+ return "failed"
271
+ try:
272
+ after = {t["id"] for t in toolsetup.missing_tools(d.probe_tools())}
273
+ except Exception: # noqa: BLE001
274
+ after = {t["id"] for t in missing}
275
+ still = [t["label"] for t in missing if t["id"] in after]
276
+ if not still:
277
+ d.say(" ✓ Installed")
278
+ return "installed"
279
+ d.say(f" Still missing: {', '.join(still)} — \"Install with the agent\" in Code can try, or run setup again.")
280
+ return "partly"
281
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Dist: segno>=1.5
21
21
  Requires-Dist: tinfoil<0.15,>=0.14
22
22
  Provides-Extra: test
23
23
  Requires-Dist: pytest>=8; extra == "test"
24
+ Requires-Dist: pyflakes>=3; extra == "test"
24
25
  Dynamic: license-file
25
26
 
26
27
  # witbitz-code
@@ -48,9 +49,11 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
48
49
  witbitz-code setup
49
50
  ```
50
51
 
51
- `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
+ `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
53
  this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), which gives the computer **its
53
- own keys from your Spaces account**, then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
+ own keys from your Spaces account**, offers to install the tools the agent uses for audio, video, PDFs and scans (ffmpeg,
55
+ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it shows what it will run and asks once), then
56
+ starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
57
  stops it); next time, `witbitz-code serve` is enough.
55
58
 
56
59
  **No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
@@ -37,7 +37,9 @@ src/witbitz_code/receipt.py
37
37
  src/witbitz_code/relay.py
38
38
  src/witbitz_code/seen.py
39
39
  src/witbitz_code/tinfoil_read.py
40
+ src/witbitz_code/tools_catalog.json
40
41
  src/witbitz_code/tools_probe.py
42
+ src/witbitz_code/toolsetup.py
41
43
  src/witbitz_code/uninstall.py
42
44
  src/witbitz_code/winagent.py
43
45
  src/witbitz_code/winconsole.py
@@ -72,6 +74,7 @@ tests/test_registry.py
72
74
  tests/test_relay_codec.py
73
75
  tests/test_setup.py
74
76
  tests/test_tools_probe.py
77
+ tests/test_toolsetup.py
75
78
  tests/test_uninstall.py
76
79
  tests/test_winagent.py
77
80
  tests/test_winconsole.py
@@ -6,3 +6,4 @@ tinfoil<0.15,>=0.14
6
6
 
7
7
  [test]
8
8
  pytest>=8
9
+ pyflakes>=3
@@ -120,7 +120,9 @@ class Fake:
120
120
  code_keys=lambda p: {"caps": {"modelsUsd": 50, "toolsUsd": 5}} if p.get("computerId") in self._keyed else None,
121
121
  serve_here=serve_here,
122
122
  # never this machine's real processes: the real lookup finds whatever listens on 4096 HERE (an OpenCode does)
123
- **{"is_listening": listening, "port_owners": lambda port: [], "stop_process": lambda pid: False, "wsl": False, **over})
123
+ # and never this machine's tools: nothing is missing, nothing is installed (the Tools step has its own tests)
124
+ **{"is_listening": listening, "port_owners": lambda port: [], "stop_process": lambda pid: False, "wsl": False,
125
+ "probe_tools": lambda: {"tools": {}, "platform": {"os": "", "pm": ""}}, "run_tools": lambda plan: {}, "refresh_path": lambda: None, **over})
124
126
 
125
127
 
126
128
  def test_a_new_computer_is_paired_gets_its_keys_from_the_account_and_is_served_without_a_key_prompt():
@@ -0,0 +1,205 @@
1
+ """setup's Tools step (toolsetup.py) — the plan held to codeTools.js installPlan, the runner, the pinned download, PATH after
2
+ installs, and the wizard step. Nothing here installs anything on this computer: every command and download is a fake."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import hashlib
8
+ import io
9
+ import zipfile
10
+
11
+ import httpx
12
+ import pytest
13
+ from conftest import requires_node, run_node
14
+
15
+ from witbitz_code import toolsetup as ts, wizard
16
+
17
+ CASES = [
18
+ (["git", "ripgrep", "ffmpeg", "whisper", "poppler", "uv", "tesseract"], {"os": "win32", "pm": "winget"}),
19
+ (["git", "ffmpeg", "whisper", "poppler", "uv", "pandoc"], {"os": "linux", "pm": "apt"}),
20
+ (["ffmpeg", "whisper", "poppler", "qpdf"], {"os": "darwin", "pm": "brew"}),
21
+ (["ffmpeg", "poppler"], {"os": "win32", "pm": "scoop"}),
22
+ (["jq", "whisper"], {"os": "darwin", "pm": ""}),
23
+ (["git"], {"os": "linux", "pm": "zypper"}),
24
+ ]
25
+
26
+
27
+ @requires_node
28
+ def test_the_plans_and_the_catalog_copy_are_the_javascript_ones():
29
+ js = run_node("import { installPlan, missingTools } from '@REPO@/spaces/public/codeTools.js'; import { toolsCatalog } from '@TOOLS@/code-py-models.mjs';"
30
+ "OUT({ plans: INPUT.cases.map(([ids, p]) => installPlan(ids, p)), catalog: toolsCatalog(),"
31
+ " missing: INPUT.reports.map((r) => missingTools(r).map((t) => t.id)) })".replace("@REPO@", (__import__("conftest").REPO).as_uri()),
32
+ {"cases": CASES, "reports": REPORTS})
33
+ assert js["catalog"] == {"tools": ts.TOOLS, "adminLines": ts.ADMIN_LINES, "windowsReleases": ts.WINDOWS_RELEASES}, "tools_catalog.json is stale — run node tools/code-py-models.mjs"
34
+ assert [ts.install_plan(ids, p) for ids, p in CASES] == js["plans"]
35
+ assert [[t["id"] for t in ts.missing_tools(r)] for r in REPORTS] == js["missing"]
36
+
37
+
38
+ REPORTS = [
39
+ {"tools": {"whisper": False, "ffmpeg": False, "git": True}, "platform": {"os": "win32", "pm": "winget"}},
40
+ {"tools": {"whisper": False, "ffmpeg": False}, "platform": {"os": "darwin", "pm": "brew"}},
41
+ {"tools": {"whisper": False}, "platform": {"os": "linux", "pm": "apt"}},
42
+ ]
43
+
44
+
45
+ def test_whisper_is_never_missing_where_the_media_tools_fetch_it():
46
+ assert [t["id"] for t in ts.missing_tools(REPORTS[0])] == ["ffmpeg"]
47
+ assert [t["id"] for t in ts.missing_tools(REPORTS[1])] == ["ffmpeg", "whisper"], "on a Mac it comes from Homebrew"
48
+ assert ts.missing_tools(REPORTS[2]) == []
49
+
50
+
51
+ def test_the_runner_says_each_step_and_sorts_the_outcomes():
52
+ said, ran = [], []
53
+ plan = ts.install_plan(["git", "ffmpeg", "poppler", "uv", "whisper"], {"os": "win32", "pm": "winget"}) + [{"kind": "none", "id": "pandoc", "note": ""}]
54
+ def run(argv):
55
+ ran.append(argv)
56
+ return 0 if "Git.Git" in argv else 1
57
+ out = ts.run_plan(plan, say=said.append, run=run, release=lambda st: "C:\\W\\poppler\\bin")
58
+ assert out == {"done": ["git", "poppler"], "failed": ["uv", "ffmpeg"], "auto": ["whisper"], "none": ["pandoc"]}
59
+ assert all(a[:2] == ["winget", "install"] and "--accept-package-agreements" in a for a in ran)
60
+ assert any("ffmpeg: that ended with 1" in s for s in said) and any("Poppler (42 MB)" in s for s in said)
61
+
62
+
63
+ def _zip(files: dict) -> bytes:
64
+ b = io.BytesIO()
65
+ with zipfile.ZipFile(b, "w") as z:
66
+ for name, data in files.items():
67
+ z.writestr(name, data)
68
+ return b.getvalue()
69
+
70
+
71
+ def test_the_pinned_download_lands_its_inner_folder_and_a_changed_file_is_refused(tmp_path):
72
+ body = _zip({"poppler-26.09.0/Library/bin/pdftotext.exe": b"MZ-text", "poppler-26.09.0/Library/bin/poppler.dll": b"MZ-dll",
73
+ "poppler-26.09.0/share/x.txt": b"no", "poppler-26.09.0/Library/bin/../../evil.txt": b"no"})
74
+ step = {"url": "https://example.test/p.zip", "sha256": hashlib.sha256(body).hexdigest(), "mb": 1, "inner": "poppler-26.09.0/Library/bin/", "dir": "poppler\\bin"}
75
+ with httpx.Client(transport=httpx.MockTransport(lambda req: httpx.Response(200, content=body))) as c:
76
+ to = ts.install_release(step, client=c, dest=str(tmp_path))
77
+ got = sorted(p.name for p in (tmp_path / "poppler" / "bin").iterdir())
78
+ assert got == ["pdftotext.exe", "poppler.dll"] and to.endswith("bin") and not (tmp_path / "evil.txt").exists()
79
+ bad = {**step, "dir": "other\\bin"}
80
+ with httpx.Client(transport=httpx.MockTransport(lambda req: httpx.Response(200, content=body + b"x"))) as c:
81
+ with pytest.raises(ValueError, match="not the pinned file"):
82
+ ts.install_release(bad, client=c, dest=str(tmp_path))
83
+ assert not (tmp_path / "other").exists(), "nothing written from a changed file"
84
+
85
+
86
+ def test_path_after_installs_comes_from_the_registry_and_keeps_this_process_own_entries():
87
+ class Reg:
88
+ HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER = "HKLM", "HKCU"
89
+ vals = {"HKLM": r"C:\Windows;%SystemRoot%\x", "HKCU": r"C:\Users\u\AppData\Local\Microsoft\WinGet\Links"}
90
+ class _K:
91
+ def __init__(self, root): self.root = root
92
+ def __enter__(self): return self
93
+ def __exit__(self, *a): return False
94
+ def OpenKey(self, root, key): return Reg._K(root) # noqa: N802
95
+ def QueryValueEx(self, k, name): return Reg.vals[k.root], 2 # noqa: N802
96
+ def ExpandEnvironmentStrings(self, v): return v.replace("%SystemRoot%", r"C:\Windows") # noqa: N802
97
+ reg = ts.registry_path(Reg())
98
+ assert reg == r"C:\Windows;C:\Windows\x;C:\Users\u\AppData\Local\Microsoft\WinGet\Links"
99
+ assert ts.merged_path(r"C:\W\bin;c:\windows", reg) == r"C:\Windows;C:\Windows\x;C:\Users\u\AppData\Local\Microsoft\WinGet\Links;C:\W\bin"
100
+
101
+
102
+ class Tools:
103
+ """setup's view of this computer's tools: `missing` until run_tools installs them (or `installs` says which)."""
104
+
105
+ def __init__(self, missing: list[str], platform: dict, installs: list[str] | None = None) -> None:
106
+ self.missing, self.platform, self.installs, self.plans, self.refreshed = set(missing), platform, installs, [], 0
107
+
108
+ def probe(self) -> dict:
109
+ return {"tools": {t["id"]: t["id"] not in self.missing for t in ts.TOOLS}, "platform": self.platform}
110
+
111
+ def run(self, plan: list) -> dict:
112
+ self.plans.append(plan)
113
+ for st in plan:
114
+ for i in st.get("ids") or [st.get("id")]:
115
+ if self.installs is None or i in self.installs:
116
+ self.missing.discard(i)
117
+ return {}
118
+
119
+
120
+ def step(tools: Tools, answers: list[str]) -> tuple[str, list[str]]:
121
+ said = []
122
+ d = wizard.Deps(say=said.append, ask=lambda q: answers.pop(0) if answers else "", secret=lambda q: "",
123
+ probe_tools=tools.probe, run_tools=tools.run, refresh_path=lambda: setattr(tools, "refreshed", tools.refreshed + 1))
124
+ return asyncio.run(wizard._tools_step(d)), said
125
+
126
+
127
+ def test_setup_asks_once_installs_mechanically_and_checks_again():
128
+ t = Tools(["ffmpeg", "poppler", "tesseract", "whisper"], {"os": "win32", "pm": "winget"})
129
+ r, said = step(t, [""])
130
+ assert r == "installed" and t.refreshed == 1
131
+ assert [s["kind"] for s in t.plans[0]] == ["cmd", "release", "cmd"], "Whisper is not asked for on Windows: it comes by itself"
132
+ assert any("Missing here: ffmpeg, Poppler, Tesseract." in s for s in said) and any("42 MB download" in s for s in said)
133
+
134
+
135
+ def test_a_no_installs_nothing_and_what_could_not_be_installed_is_named():
136
+ t = Tools(["ffmpeg", "pandoc"], {"os": "linux", "pm": "apt"})
137
+ r, said = step(t, ["n"])
138
+ assert r == "skipped" and t.plans == []
139
+ t2 = Tools(["ffmpeg", "pandoc"], {"os": "linux", "pm": "apt"}, installs=["ffmpeg"])
140
+ r2, said2 = step(t2, [""])
141
+ assert r2 == "partly" and any("(asks for your password)" in s for s in said2) and any("Still missing: Pandoc" in s for s in said2)
142
+
143
+
144
+ def test_no_package_manager_says_so_without_asking():
145
+ t = Tools(["jq"], {"os": "darwin", "pm": ""})
146
+ r, said = step(t, [])
147
+ assert r == "unavailable" and t.plans == [] and any("no package manager found" in s for s in said)
148
+
149
+
150
+ def test_an_install_that_breaks_never_ends_setup():
151
+ # 1.3.5 on Windows: cli.py never imported toolsetup, so the first "y" died "FATAL — name 'toolsetup' is not defined"
152
+ t = Tools(["jq"], {"os": "win32", "pm": "winget"})
153
+
154
+ def broken(plan: list) -> dict:
155
+ raise NameError("name 'toolsetup' is not defined")
156
+
157
+ t.run = broken # type: ignore[method-assign]
158
+ r, said = step(t, [""])
159
+ assert r == "failed"
160
+ assert any("Could not install them (name 'toolsetup' is not defined) — setup goes on" in s for s in said)
161
+
162
+
163
+ def test_setup_hands_the_wizard_the_real_installer_and_path_refresh(monkeypatch):
164
+ # the wizard's own tests fake run_tools/refresh_path, so only this one runs the lambdas cli.py builds
165
+ from witbitz_code import cli
166
+
167
+ seen: dict = {}
168
+
169
+ async def capture(d):
170
+ seen["d"] = d
171
+ return {"served": 0}
172
+
173
+ monkeypatch.setattr(wizard, "run_setup", capture)
174
+ asyncio.run(cli.setup([]))
175
+ seen["d"].refresh_path()
176
+ assert seen["d"].run_tools([]) == {"done": [], "failed": [], "auto": [], "none": []}
177
+
178
+
179
+ def test_no_module_uses_a_name_it_never_defined():
180
+ # pyflakes: the two missing imports that reached a release (ports in 1.3.3's serve, toolsetup in 1.3.5's setup)
181
+ from pathlib import Path
182
+
183
+ import pyflakes.api
184
+ import pyflakes.messages
185
+
186
+ import witbitz_code
187
+
188
+ class Undefined:
189
+ def __init__(self) -> None:
190
+ self.found: list[str] = []
191
+
192
+ def unexpectedError(self, filename, msg): # noqa: N802 — pyflakes' reporter interface
193
+ self.found.append(f"{filename}: {msg}")
194
+
195
+ def syntaxError(self, filename, msg, lineno, offset, text): # noqa: N802
196
+ self.found.append(f"{filename}:{lineno}: {msg}")
197
+
198
+ def flake(self, message):
199
+ if isinstance(message, pyflakes.messages.UndefinedName):
200
+ self.found.append(str(message))
201
+
202
+ rep = Undefined()
203
+ for f in sorted(Path(witbitz_code.__file__).parent.glob("*.py")):
204
+ pyflakes.api.check(f.read_text(encoding="utf-8"), str(f), rep)
205
+ assert rep.found == []
@@ -67,3 +67,13 @@ def test_apply_puts_python_ahead_of_ast_grep_and_adds_to_the_instructions():
67
67
  # no ast-grep: PATH starts from the base
68
68
  env, config = winagent.apply({}, {}, add, None, base_path=r"C:\Windows\system32")
69
69
  assert env["PATH"] == r"C:\py\Scripts;C:\Windows\system32" and config == {}
70
+
71
+
72
+ def test_setups_poppler_joins_the_agents_path_when_it_is_there(tmp_path):
73
+ # setup's Tools step puts Poppler in Witbitz\poppler\bin (winget has no package) — the agent's PATH must reach it
74
+ py = r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts\python.exe"
75
+ pdf = r"C:\Users\t\AppData\Local\Witbitz\poppler\bin\pdftotext.exe"
76
+ add, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(py, pdf))
77
+ assert add["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts", r"C:\Users\t\AppData\Local\Witbitz\poppler\bin"]
78
+ add2, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(py))
79
+ assert add2["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts"]
File without changes
File without changes