witbitz-code 1.3.4__tar.gz → 1.3.5__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.5}/PKG-INFO +5 -3
  2. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/README.md +4 -2
  3. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/pyproject.toml +2 -2
  4. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/cli.py +6 -0
  6. witbitz_code-1.3.5/src/witbitz_code/tools_catalog.json +219 -0
  7. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/tools_probe.py +8 -1
  8. witbitz_code-1.3.5/src/witbitz_code/toolsetup.py +179 -0
  9. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/winagent.py +5 -2
  10. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/wizard.py +60 -3
  11. {witbitz_code-1.3.4 → witbitz_code-1.3.5/src/witbitz_code.egg-info}/PKG-INFO +5 -3
  12. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
  13. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_setup.py +3 -1
  14. witbitz_code-1.3.5/tests/test_toolsetup.py +147 -0
  15. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_winagent.py +10 -0
  16. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/LICENSE +0 -0
  17. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/setup.cfg +0 -0
  18. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/__main__.py +0 -0
  19. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/_js.py +0 -0
  20. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/account.py +0 -0
  21. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/asks.py +0 -0
  22. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/attachments.py +0 -0
  23. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/auto.py +0 -0
  24. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/auto_runner.py +0 -0
  25. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/codekeys.py +0 -0
  26. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/codetools.py +0 -0
  27. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/compress.py +0 -0
  28. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/computers.py +0 -0
  29. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/confidential.py +0 -0
  30. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/confidential_models.json +0 -0
  31. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/connector.py +0 -0
  32. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/devicelink.py +0 -0
  33. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/keys.py +0 -0
  36. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/link.py +0 -0
  37. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/models.py +0 -0
  38. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/net.py +0 -0
  39. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/offered_models.json +0 -0
  40. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  41. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  42. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  43. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  44. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/outputs.py +0 -0
  45. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/pair.py +0 -0
  46. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/pairings.py +0 -0
  47. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/payers.py +0 -0
  48. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/plugins.py +0 -0
  49. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/policy.py +0 -0
  50. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/ports.py +0 -0
  51. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/qr.py +0 -0
  52. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/receipt.py +0 -0
  53. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/relay.py +0 -0
  54. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/seen.py +0 -0
  55. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/tinfoil_read.py +0 -0
  56. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/uninstall.py +0 -0
  57. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code/winconsole.py +0 -0
  58. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  59. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  60. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code.egg-info/requires.txt +0 -0
  61. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/src/witbitz_code.egg-info/top_level.txt +0 -0
  62. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_asks.py +0 -0
  63. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_attachments.py +0 -0
  64. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_auto.py +0 -0
  65. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_cli.py +0 -0
  66. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_codekeys.py +0 -0
  67. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_codetools.py +0 -0
  68. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_confidential.py +0 -0
  69. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_connector_e2e.py +0 -0
  70. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_devicelink.py +0 -0
  71. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_files_and_compress.py +0 -0
  72. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_folders.py +0 -0
  73. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_outputs.py +0 -0
  74. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_pair_flows.py +0 -0
  75. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_plugins.py +0 -0
  76. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_policy.py +0 -0
  77. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_ports.py +0 -0
  78. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_registry.py +0 -0
  79. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_relay_codec.py +0 -0
  80. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_tools_probe.py +0 -0
  81. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_uninstall.py +0 -0
  82. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/tests/test_winconsole.py +0 -0
  83. {witbitz_code-1.3.4 → witbitz_code-1.3.5}/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.5
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
@@ -48,9 +48,11 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
48
48
  witbitz-code setup
49
49
  ```
50
50
 
51
- `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
51
+ `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
52
  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
53
+ own keys from your Spaces account**, offers to install the tools the agent uses for audio, video, PDFs and scans (ffmpeg,
54
+ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it shows what it will run and asks once), then
55
+ starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
56
  stops it); next time, `witbitz-code serve` is enough.
55
57
 
56
58
  **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.5"
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"
@@ -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.5"
@@ -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:
@@ -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,51 @@ 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'."""
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
+ d.run_tools(plan)
266
+ d.refresh_path()
267
+ try:
268
+ after = {t["id"] for t in toolsetup.missing_tools(d.probe_tools())}
269
+ except Exception: # noqa: BLE001
270
+ after = {t["id"] for t in missing}
271
+ still = [t["label"] for t in missing if t["id"] in after]
272
+ if not still:
273
+ d.say(" ✓ Installed")
274
+ return "installed"
275
+ d.say(f" Still missing: {', '.join(still)} — \"Install with the agent\" in Code can try, or run setup again.")
276
+ return "partly"
277
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.4
3
+ Version: 1.3.5
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
@@ -48,9 +48,11 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
48
48
  witbitz-code setup
49
49
  ```
50
50
 
51
- `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
51
+ `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
52
  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
53
+ own keys from your Spaces account**, offers to install the tools the agent uses for audio, video, PDFs and scans (ffmpeg,
54
+ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it shows what it will run and asks once), then
55
+ starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
56
  stops it); next time, `witbitz-code serve` is enough.
55
57
 
56
58
  **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
@@ -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,147 @@
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)
@@ -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