davinci-resolve-mcp 2.98.0 → 2.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,120 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.98.2
6
+
7
+ **A tool installed next to the server was invisible to it.** Reported in
8
+ [#153](https://github.com/samuelgursky/davinci-resolve-mcp/issues/153) by
9
+ @7daysdedicated as an encoding fault in the whisper probe. The probe turned out
10
+ not to be the cause, and the encoding fault turned out to be real somewhere
11
+ else, so both are fixed here.
12
+
13
+ ### Fixed
14
+
15
+ - **The server's own virtualenv was not searched for command-line tools.**
16
+ `pip install openai-whisper` writes a `whisper` executable into
17
+ `venv/Scripts` on Windows and `venv/bin` elsewhere, and that directory is on
18
+ PATH only while the environment is *activated* — which nothing does, since the
19
+ client launches `venv/python server.py` directly. So `shutil.which("whisper")`
20
+ returned None and `capabilities` reported `whisper_cli.available: false` for a
21
+ tool sitting beside the interpreter looking for it, with nothing in the
22
+ response to say why. The interpreter's script directory now leads the PATH
23
+ augmentation that already covered Homebrew and `/usr/local`. Not
24
+ Windows-specific: the same gap existed on macOS and Linux.
25
+
26
+ - **Text-mode reads of a child process decoded with the locale codec.** Nineteen
27
+ `subprocess.run(..., text=True)` calls across the server, panel, and utils had
28
+ no `encoding=`, so Python used the platform locale — cp1252 on a default
29
+ Windows install, a codec with no mapping for most of what a media tool prints.
30
+ A clip name in Japanese from the advanced-server bridge, a localized WMIC
31
+ banner, or a user script's output was then a `UnicodeDecodeError` raised
32
+ inside a call whose job was to answer a yes/no question. All nineteen now read
33
+ UTF-8 with `errors="replace"`, so the answer can be wrong in the last
34
+ character but never an exception. The WMIC read matters most: it feeds the
35
+ second-instance guard fixed in v2.97.6, and it must fail to "cannot tell".
36
+
37
+ - **Child Python processes are handed `PYTHONIOENCODING=utf-8`.** A script run
38
+ through `resolve_control` writes into a pipe, where Python picks the locale
39
+ codepage rather than the console's, so printing a non-Latin-1 character killed
40
+ the script with `UnicodeEncodeError` — and the failure read as the script's
41
+ fault rather than the pipe's. The transcription path already did this; the
42
+ script-runner did not.
43
+
44
+ ### Added
45
+
46
+ - **`tests.test_child_process_text_encoding`** — a static walk over `src/` that
47
+ fails on any text-mode child read without an explicit `encoding=`. Static
48
+ because the exception needs a non-UTF-8 locale to reproduce and no machine in
49
+ this suite has one: the missing argument is visible in the source, the
50
+ `UnicodeDecodeError` is only visible in Tokyo. The walk carries a test that it
51
+ can still see an offender, since a guard that quietly matches nothing passes
52
+ forever. Also covers the venv script directory being on PATH, first, and
53
+ idempotently.
54
+
55
+ ### Note on the report
56
+
57
+ The diagnosis in #153 named `whisper --help` as the probe. That string is
58
+ display text in the install-guidance table and is never executed — detection is
59
+ `shutil.which("whisper")` — and the transcription path already decoded UTF-8 and
60
+ already set `PYTHONIOENCODING`. The cause was the PATH gap the report mentioned
61
+ last, in passing, as an aside about `pip`. Worth stating plainly, because the
62
+ reporter's `whisper.cmd` workaround set the encoding *and* put a `whisper` on
63
+ PATH, and only the second half was doing the work.
64
+
65
+ ### Changed
66
+
67
+ - `docs/install.md` says which environment optional packages have to be
68
+ installed into, which is the part that was undocumented.
69
+
70
+ ## What's New in v2.98.1
71
+
72
+ **The Bash guard could be walked past with a newline.** Reported and fixed in
73
+ [#152](https://github.com/samuelgursky/davinci-resolve-mcp/pull/152) by
74
+ @fitfam, who found both holes by executing payloads against the hook rather
75
+ than reading it.
76
+
77
+ ### Fixed
78
+
79
+ - **`split_commands()` did not split on newlines.** It knew `&&`, `||`, `;` and
80
+ `|`, so a multi-line block was a single segment: `argv0` came from the first
81
+ line, and a non-mutating first line returned before anything under it was
82
+ looked at. `mkdir -p footage/_superseded` then a newline then
83
+ `mv footage/clip.mp4 footage/_superseded/` passed, while the same two commands
84
+ joined by `;` denied. Either answer is defensible on its own; giving two
85
+ different answers to what a shell treats as the same command is what made the
86
+ guard trainable around.
87
+
88
+ - **The ffmpeg branch exempted an output that equalled an input.** The output
89
+ operand was dropped when it matched a `-i` value — which is exactly
90
+ `ffmpeg -i master.mp4 master.mp4`, the in-place overwrite the hook's own deny
91
+ message calls unrecoverable. Independent of the first hole: the single-line
92
+ form was allowed too.
93
+
94
+ ### Changed
95
+
96
+ - The trailing operand is no longer read as an output when it is the value of a
97
+ `-i` immediately before it. `ffmpeg -i master.mov` prints stream info and
98
+ writes nothing, and denying a read is how a guard teaches an agent to route
99
+ around it. `ffmpeg -i x.mp4 x.mp4` still denies — the token there is not
100
+ preceded by `-i`.
101
+
102
+ ### Added
103
+
104
+ - **`tests.test_source_media_guard`** — the hook had no tests, which is why two
105
+ holes in it needed a payload matrix to find. It runs the hook the way the
106
+ harness does, a PreToolUse payload on stdin and a decision on stdout, and
107
+ fails on exactly the four rows this release fixes. The newline/`;` pair is
108
+ asserted as a pair: not that either answer is right, but that the guard cannot
109
+ give two.
110
+
111
+ ### Note
112
+
113
+ - Splitting on newlines means multi-line text carried *inside* a command — a
114
+ heredoc body, a commit message — is now read as commands too, so text that
115
+ merely quotes `rm camera.mov` is denied. That is the right way round for a
116
+ tripwire; write the text to a file first. #152 was itself blocked this way on
117
+ its first attempt, by the fix it was proposing.
118
+
5
119
  ## What's New in v2.98.0
6
120
 
7
121
  **The control panel could be driven by any web page you visited.** Reported
@@ -117,6 +231,13 @@ state file and panel pidfile move out of shared locations at the same time.
117
231
  response, so an unrelated document sitting in the export folder could reach an
118
232
  assistant's context. Only staged files are read now.
119
233
 
234
+ Live-verified after release on Studio 19.1.3.7 (macOS, Color page, Gallery
235
+ panel open): Resolve's `ExportStills` writes into the staging subdirectory — a
236
+ 257 KB JPEG and its 28 KB `.drx` came back inlined — a bystander file in the
237
+ same folder survived untouched, the folder itself was kept, and
238
+ `cleanup: false` moved both files up into it. That subdirectory write was the
239
+ one claim the offline fixtures could not make.
240
+
120
241
  ### Added
121
242
 
122
243
  - `tests.test_gallery_still_export_cleanup` runs the action against a real temp
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.98.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.98.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.98.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.98.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/install.md CHANGED
@@ -35,6 +35,14 @@ rather than guessing** if it is absent:
35
35
  `python scripts/doctor.py` reports which are present. `media_analysis`
36
36
  `capabilities` covers the analysis stack in more detail.
37
37
 
38
+ **Install them into the environment the server runs from.** A managed install
39
+ uses its own virtualenv, and `pip install` from an unrelated shell puts the
40
+ package somewhere that interpreter never looks. Use the venv's own pip —
41
+ `<install>/venv/bin/pip` (`venv\Scripts\pip.exe` on Windows) — or activate it
42
+ first. Command-line tools that come with a package (`openai-whisper` installs a
43
+ `whisper` executable) are found in that venv's script directory automatically
44
+ since v2.98.2; before that they had to be on PATH by hand.
45
+
38
46
  Model weights carry their own licences, separate from the code that loads them.
39
47
 
40
48
  > **Python 3.13 / 3.14:** these are **allowed** — setup will use them and warn.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.98.0"
40
+ VERSION = "2.98.2"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.98.0",
3
+ "version": "2.98.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -14569,7 +14569,8 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
14569
14569
  try:
14570
14570
  check = subprocess.run(
14571
14571
  ["osascript", "-e", 'application "iTerm" is running'],
14572
- capture_output=True, text=True, timeout=8,
14572
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
14573
+ timeout=8,
14573
14574
  )
14574
14575
  iterm_running = (check.stdout or "").strip().lower() == "true"
14575
14576
  except Exception:
@@ -14592,7 +14593,8 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
14592
14593
  try:
14593
14594
  proc = subprocess.run(
14594
14595
  ["osascript", "-e", script],
14595
- capture_output=True, text=True, timeout=15,
14596
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
14597
+ timeout=15,
14596
14598
  )
14597
14599
  if proc.returncode != 0:
14598
14600
  return {"success": False, "error": (proc.stderr or "").strip() or "osascript failed"}
@@ -14623,7 +14625,8 @@ def _native_directory_picker(initial: Optional[str] = None) -> Dict[str, Any]:
14623
14625
  import subprocess
14624
14626
  proc = subprocess.run(
14625
14627
  ["osascript", "-e", script],
14626
- capture_output=True, text=True, timeout=120,
14628
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
14629
+ timeout=120,
14627
14630
  )
14628
14631
  if proc.returncode != 0:
14629
14632
  stderr = (proc.stderr or "").strip()
@@ -15194,7 +15197,11 @@ def _run_advanced_bridge(surface: str, op: str, args: Optional[Dict[str, Any]] =
15194
15197
  # stdin=DEVNULL: never let a child race-read a protocol/stdin stream (api_truth).
15195
15198
  proc = subprocess.run(
15196
15199
  [node, bridge, str(surface), str(op), json.dumps(args or {})],
15197
- capture_output=True, text=True, timeout=timeout,
15200
+ # The bridge answers in JSON that carries clip and project names;
15201
+ # decoding those with the locale codepage is how a non-ASCII name
15202
+ # turns a working panel call into a UnicodeDecodeError (#153).
15203
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
15204
+ timeout=timeout,
15198
15205
  stdin=subprocess.DEVNULL, cwd=_advanced_root(),
15199
15206
  )
15200
15207
  except subprocess.TimeoutExpired:
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.98.0"
90
+ VERSION = "2.98.2"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.98.0"
14
+ VERSION = "2.98.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1724,8 +1724,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1724
1724
  import subprocess
1725
1725
  proc = subprocess.run(
1726
1726
  ["osascript", "-e", 'tell application "DaVinci Resolve" to activate'],
1727
- capture_output=True, text=True, timeout=5,
1728
- stdin=subprocess.DEVNULL,
1727
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1728
+ timeout=5, stdin=subprocess.DEVNULL,
1729
1729
  )
1730
1730
  return {
1731
1731
  "activated": proc.returncode == 0,
@@ -1738,8 +1738,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1738
1738
  ["powershell", "-NoProfile", "-Command",
1739
1739
  "$s = New-Object -ComObject WScript.Shell; "
1740
1740
  "$null = $s.AppActivate('DaVinci Resolve')"],
1741
- capture_output=True, text=True, timeout=5,
1742
- stdin=subprocess.DEVNULL,
1741
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1742
+ timeout=5, stdin=subprocess.DEVNULL,
1743
1743
  )
1744
1744
  return {
1745
1745
  "activated": proc.returncode == 0,
@@ -1751,8 +1751,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1751
1751
  if shutil.which("wmctrl"):
1752
1752
  proc = subprocess.run(
1753
1753
  ["wmctrl", "-a", "DaVinci Resolve"],
1754
- capture_output=True, text=True, timeout=5,
1755
- stdin=subprocess.DEVNULL,
1754
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1755
+ timeout=5, stdin=subprocess.DEVNULL,
1756
1756
  )
1757
1757
  return {
1758
1758
  "activated": proc.returncode == 0,
@@ -1762,8 +1762,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
1762
1762
  if shutil.which("xdotool"):
1763
1763
  proc = subprocess.run(
1764
1764
  ["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"],
1765
- capture_output=True, text=True, timeout=5,
1766
- stdin=subprocess.DEVNULL,
1765
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1766
+ timeout=5, stdin=subprocess.DEVNULL,
1767
1767
  )
1768
1768
  return {
1769
1769
  "activated": proc.returncode == 0,
@@ -1794,8 +1794,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1794
1794
  )
1795
1795
  proc = subprocess.run(
1796
1796
  ["osascript", "-e", script],
1797
- capture_output=True, text=True, timeout=5,
1798
- stdin=subprocess.DEVNULL,
1797
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1798
+ timeout=5, stdin=subprocess.DEVNULL,
1799
1799
  )
1800
1800
  return {
1801
1801
  "sent": proc.returncode == 0,
@@ -1810,8 +1810,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1810
1810
  "Add-Type -AssemblyName System.Windows.Forms; "
1811
1811
  "Start-Sleep -Milliseconds 150; "
1812
1812
  "[System.Windows.Forms.SendKeys]::SendWait('+i')"],
1813
- capture_output=True, text=True, timeout=5,
1814
- stdin=subprocess.DEVNULL,
1813
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1814
+ timeout=5, stdin=subprocess.DEVNULL,
1815
1815
  )
1816
1816
  return {
1817
1817
  "sent": proc.returncode == 0,
@@ -1824,8 +1824,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1824
1824
  if shutil.which("xdotool"):
1825
1825
  proc = subprocess.run(
1826
1826
  ["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"],
1827
- capture_output=True, text=True, timeout=5,
1828
- stdin=subprocess.DEVNULL,
1827
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1828
+ timeout=5, stdin=subprocess.DEVNULL,
1829
1829
  )
1830
1830
  return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"}
1831
1831
  return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"}
@@ -15286,7 +15286,8 @@ def _port_owner_pid(host: str, port: int) -> Optional[int]:
15286
15286
  try:
15287
15287
  result = subprocess.run(
15288
15288
  ["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"],
15289
- capture_output=True, timeout=3, text=True, check=False,
15289
+ capture_output=True, timeout=3, text=True, encoding="utf-8",
15290
+ errors="replace", check=False,
15290
15291
  stdin=subprocess.DEVNULL,
15291
15292
  )
15292
15293
  except (OSError, subprocess.TimeoutExpired):
@@ -16561,6 +16562,8 @@ def _make_spec_hook_runner(timeout: float = 120.0):
16561
16562
  stdin=subprocess.DEVNULL,
16562
16563
  capture_output=True,
16563
16564
  text=True,
16565
+ encoding="utf-8",
16566
+ errors="replace",
16564
16567
  )
16565
16568
  return proc.returncode == 0
16566
16569
  except Exception as exc:
@@ -27141,7 +27144,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
27141
27144
  f.write(source)
27142
27145
  tmp = f.name
27143
27146
  try:
27144
- result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10,
27147
+ result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True,
27148
+ encoding="utf-8", errors="replace", timeout=10,
27145
27149
  stdin=subprocess.DEVNULL)
27146
27150
  if result.returncode == 0:
27147
27151
  return {"valid": True, "errors": None, "checker": luac}
@@ -27694,6 +27698,12 @@ def _python_env_for_resolve() -> Dict[str, str]:
27694
27698
  env = os.environ.copy()
27695
27699
  env["RESOLVE_SCRIPT_API"] = RESOLVE_API_PATH
27696
27700
  env["RESOLVE_SCRIPT_LIB"] = RESOLVE_LIB_PATH
27701
+ # The child writes its stdout into a pipe, so Python picks the locale
27702
+ # codepage rather than the console's — cp1252 on a default Windows install.
27703
+ # A script that prints a non-Latin-1 character then dies with
27704
+ # UnicodeEncodeError instead of returning its output, and the failure is
27705
+ # attributed to the script rather than to the pipe it was handed (#153).
27706
+ env["PYTHONIOENCODING"] = "utf-8"
27697
27707
  pp = env.get("PYTHONPATH", "")
27698
27708
  if RESOLVE_MODULES_PATH not in pp:
27699
27709
  env["PYTHONPATH"] = (RESOLVE_MODULES_PATH +
@@ -27741,7 +27751,8 @@ def _execute_python_script(path: str, args: List[str],
27741
27751
  cmd = [sys.executable, "-c", _PY_SCRIPT_EXIT_GUARD, path] + [str(a) for a in args]
27742
27752
  try:
27743
27753
  result = safe_run(cmd, env=_python_env_for_resolve(),
27744
- capture_output=True, text=True, timeout=timeout)
27754
+ capture_output=True, text=True, encoding="utf-8",
27755
+ errors="replace", timeout=timeout)
27745
27756
  except subprocess.TimeoutExpired as e:
27746
27757
  return _err(f"Script timed out after {timeout}s. "
27747
27758
  f"Partial stdout: {(e.stdout or '')[:1000]}")
@@ -33,6 +33,8 @@ def _run_app_command(
33
33
  check=False,
34
34
  capture_output=True,
35
35
  text=True,
36
+ encoding="utf-8",
37
+ errors="replace",
36
38
  timeout=timeout,
37
39
  )
38
40
  except subprocess.TimeoutExpired:
@@ -189,7 +189,8 @@ def _probe_color_transfer(path: str) -> Optional[str]:
189
189
  "-show_entries", "stream=color_transfer", "-of", "default=nw=1:nk=1", path,
190
190
  ]
191
191
  try:
192
- proc = subprocess.run(args, capture_output=True, text=True, timeout=30, check=False)
192
+ proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8",
193
+ errors="replace", timeout=30, check=False)
193
194
  except (subprocess.TimeoutExpired, OSError):
194
195
  return None
195
196
  value = (proc.stdout or "").strip().lower()
@@ -335,8 +335,20 @@ def _ensure_path_includes_standard_tool_dirs() -> None:
335
335
  /opt/homebrew/bin/ffprobe. Subprocess calls (subprocess.run(["ffprobe"...]))
336
336
  then also fail to find the binary. Prepending the standard tool dirs here
337
337
  fixes both detection and execution for every importer of this module.
338
+
339
+ The interpreter's own script directory is one of them, and it was missing.
340
+ A console script installed into the server's virtualenv — `pip install
341
+ openai-whisper` puts `whisper` in `venv/Scripts` on Windows, `venv/bin`
342
+ elsewhere — is only on PATH when that environment has been *activated*, and
343
+ nothing activates it: the client launches `venv/python server.py` directly.
344
+ So the tool was installed, working, and invisible, and `capabilities`
345
+ reported `whisper_cli.available: false` with no hint as to why (#153, where
346
+ the workaround that appeared to fix it was a shim placed on PATH by hand).
338
347
  """
339
348
  candidates = [
349
+ # The venv this server is running from, first: a tool installed
350
+ # deliberately alongside it should win over an older copy elsewhere.
351
+ os.path.dirname(os.path.abspath(sys.executable)),
340
352
  "/opt/homebrew/bin",
341
353
  "/opt/homebrew/sbin",
342
354
  "/usr/local/bin",
@@ -216,7 +216,8 @@ def _process_name(pid: int) -> str:
216
216
 
217
217
  out = subprocess.run(
218
218
  ["ps", "-p", str(pid), "-o", "comm="],
219
- capture_output=True, text=True, timeout=5, check=False,
219
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
220
+ timeout=5, check=False,
220
221
  )
221
222
  return (out.stdout or "").strip()
222
223
  except Exception: # pragma: no cover - defensive
@@ -68,13 +68,23 @@ def _process_lines() -> Optional[List[str]]:
68
68
  if platform.system().lower() == "windows":
69
69
  # `tasklist` prints no command line, so the flag is invisible there.
70
70
  # WMIC does print it and is what makes headless detection possible.
71
+ #
72
+ # Decoded explicitly: `text=True` alone decodes with the locale
73
+ # codepage, which raises UnicodeDecodeError on a byte cp1252 has no
74
+ # mapping for — and this read is the input to the second-instance
75
+ # guard, so it must fail to "cannot tell", never to an exception.
76
+ # ASCII is byte-identical under both codecs, so the matching this
77
+ # feeds is unchanged; what WMIC emits for a non-ASCII install path
78
+ # on a non-English Windows is not something we can verify here.
71
79
  out = subprocess.run(
72
80
  ["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
73
- capture_output=True, text=True, timeout=10, check=False,
81
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
82
+ timeout=10, check=False,
74
83
  )
75
84
  else:
76
85
  out = subprocess.run(
77
- ["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False
86
+ ["ps", "-Ao", "command="], capture_output=True, text=True,
87
+ encoding="utf-8", errors="replace", timeout=10, check=False,
78
88
  )
79
89
  if out.returncode != 0 and not out.stdout:
80
90
  return None