evo-cli 0.13.0__tar.gz → 0.14.0__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 (78) hide show
  1. {evo_cli-0.13.0 → evo_cli-0.14.0}/PKG-INFO +14 -3
  2. {evo_cli-0.13.0 → evo_cli-0.14.0}/README.md +13 -2
  3. evo_cli-0.14.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/mcp.py +27 -11
  5. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/opencode.py +10 -5
  6. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/tts.py +1 -1
  7. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/__init__.py +2 -0
  8. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/core.py +15 -0
  9. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/PKG-INFO +14 -3
  10. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_mcp.py +45 -1
  11. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_opencode.py +32 -0
  12. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_tts.py +69 -0
  13. evo_cli-0.13.0/evo_cli/VERSION +0 -1
  14. {evo_cli-0.13.0 → evo_cli-0.14.0}/Containerfile +0 -0
  15. {evo_cli-0.13.0 → evo_cli-0.14.0}/HISTORY.md +0 -0
  16. {evo_cli-0.13.0 → evo_cli-0.14.0}/LICENSE +0 -0
  17. {evo_cli-0.13.0 → evo_cli-0.14.0}/MANIFEST.in +0 -0
  18. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/__init__.py +0 -0
  19. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/__main__.py +0 -0
  20. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/base.py +0 -0
  21. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/cli.py +0 -0
  22. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/__init__.py +0 -0
  23. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/claude_code.py +0 -0
  24. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/cloudflare.py +0 -0
  25. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/cred.py +0 -0
  26. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/download.py +0 -0
  27. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/fix_claude.py +0 -0
  28. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/gdrive.py +0 -0
  29. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/gh.py +0 -0
  30. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/harness.py +0 -0
  31. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/hwid.py +0 -0
  32. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/hwid_reset.py +0 -0
  33. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/localproxy.py +0 -0
  34. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/miniconda.py +0 -0
  35. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/netcheck.py +0 -0
  36. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/plantuml.py +0 -0
  37. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/site2s.py +0 -0
  38. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/ssh.py +0 -0
  39. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/sysmon.py +0 -0
  40. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/update.py +0 -0
  41. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/commands/wifi.py +0 -0
  42. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/console.py +0 -0
  43. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/__init__.py +0 -0
  44. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/doctor.py +0 -0
  45. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/google_oauth.py +0 -0
  46. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/migrate.py +0 -0
  47. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/oauth_flow.py +0 -0
  48. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/registry.py +0 -0
  49. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/store.py +0 -0
  50. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/credentials/sync.py +0 -0
  51. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/mcp_registry.py +0 -0
  52. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/chunking.py +0 -0
  53. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/creds.py +0 -0
  54. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/errors.py +0 -0
  55. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/http.py +0 -0
  56. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/openai.py +0 -0
  57. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/player.py +0 -0
  58. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli/tts/vbee.py +0 -0
  59. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/SOURCES.txt +0 -0
  60. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  61. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/entry_points.txt +0 -0
  62. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/requires.txt +0 -0
  63. {evo_cli-0.13.0 → evo_cli-0.14.0}/evo_cli.egg-info/top_level.txt +0 -0
  64. {evo_cli-0.13.0 → evo_cli-0.14.0}/pyproject.toml +0 -0
  65. {evo_cli-0.13.0 → evo_cli-0.14.0}/setup.cfg +0 -0
  66. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/__init__.py +0 -0
  67. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_claude_code.py +0 -0
  68. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_cli.py +0 -0
  69. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_console.py +0 -0
  70. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_cred.py +0 -0
  71. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_download.py +0 -0
  72. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_fix_claude.py +0 -0
  73. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_gh.py +0 -0
  74. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_harness.py +0 -0
  75. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_plantuml.py +0 -0
  76. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_sysmon.py +0 -0
  77. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_update.py +0 -0
  78. {evo_cli-0.13.0 → evo_cli-0.14.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -214,8 +214,19 @@ evo cred add vbee.token --from-stdin # JWT from the same app page
214
214
  evo cred add openai_api_key --from-stdin
215
215
  ```
216
216
 
217
- `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
218
- (the default) picks Vbee when its credentials exist, otherwise OpenAI.
217
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set.
218
+
219
+ `--provider auto` (the default) resolves to `EVO_TTS_PROVIDER` when that is set, and otherwise to
220
+ whichever provider has credentials. Pick a machine default once:
221
+
222
+ ```bash
223
+ export EVO_TTS_PROVIDER=openai # what `auto` means here
224
+ export EVO_TTS_VOICE_OPENAI=nova # default voice for that provider only
225
+ ```
226
+
227
+ Prefer the provider-scoped `EVO_TTS_VOICE_OPENAI` / `EVO_TTS_VOICE_VBEE` over a bare
228
+ `EVO_TTS_VOICE`: a shared value breaks as soon as you pass `--provider vbee`, because an OpenAI
229
+ voice name is not a Vbee voice code.
219
230
 
220
231
  Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
221
232
  falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
@@ -186,8 +186,19 @@ evo cred add vbee.token --from-stdin # JWT from the same app page
186
186
  evo cred add openai_api_key --from-stdin
187
187
  ```
188
188
 
189
- `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
190
- (the default) picks Vbee when its credentials exist, otherwise OpenAI.
189
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set.
190
+
191
+ `--provider auto` (the default) resolves to `EVO_TTS_PROVIDER` when that is set, and otherwise to
192
+ whichever provider has credentials. Pick a machine default once:
193
+
194
+ ```bash
195
+ export EVO_TTS_PROVIDER=openai # what `auto` means here
196
+ export EVO_TTS_VOICE_OPENAI=nova # default voice for that provider only
197
+ ```
198
+
199
+ Prefer the provider-scoped `EVO_TTS_VOICE_OPENAI` / `EVO_TTS_VOICE_VBEE` over a bare
200
+ `EVO_TTS_VOICE`: a shared value breaks as soon as you pass `--provider vbee`, because an OpenAI
201
+ voice name is not a Vbee voice code.
191
202
 
192
203
  Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
193
204
  falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
@@ -0,0 +1 @@
1
+ 0.14.0
@@ -38,7 +38,7 @@ def claude_has_server(name):
38
38
  return result.returncode == 0
39
39
 
40
40
 
41
- def add_to_claude(name, spec, scope):
41
+ def add_to_claude(name, spec, scope, force=False):
42
42
  step("Adding to Claude Code")
43
43
  if not shutil.which("claude"):
44
44
  warning("`claude` CLI not found on PATH; skipping Claude Code")
@@ -46,16 +46,23 @@ def add_to_claude(name, spec, scope):
46
46
  return False
47
47
 
48
48
  if claude_has_server(name):
49
- info(f"MCP server [accent]{name}[/accent] already in Claude Code; skipping")
50
- return True
49
+ if not force:
50
+ info(f"MCP server [accent]{name}[/accent] already in Claude Code; skipping")
51
+ return True
52
+ info(f"Replacing existing [accent]{name}[/accent] in Claude Code")
53
+ run_command(["claude", "mcp", "remove", "--scope", scope, name], check=False)
51
54
 
52
55
  cmd = ["claude", "mcp", "add", "--scope", scope]
53
56
  if is_remote(spec):
54
57
  cmd += ["--transport", spec["transport"], name, spec["url"]]
55
58
  else:
59
+ # `claude mcp add` declares -e as variadic (`-e <env...>`), so any flag placed
60
+ # before the positional name swallows it as another KEY=value and the command
61
+ # dies with "Invalid environment variable format: <name>". Name first.
62
+ cmd += [name]
56
63
  for key, value in (spec.get("env") or {}).items():
57
64
  cmd += ["-e", f"{key}={value}"]
58
- cmd += [name, "--", *spec["command"]]
65
+ cmd += ["--", *spec["command"]]
59
66
  if spec.get("headers"):
60
67
  for key, value in spec["headers"].items():
61
68
  cmd += ["--header", f"{key}: {value}"]
@@ -70,20 +77,24 @@ def add_to_claude(name, spec, scope):
70
77
  return True
71
78
 
72
79
 
73
- def add_to_opencode(name, spec, project):
80
+ def add_to_opencode(name, spec, project, force=False):
74
81
  step("Adding to OpenCode")
75
82
  server_config = to_opencode_config(spec)
76
83
 
77
84
  config_path = get_global_config_path()
78
85
  existing = load_jsonc(config_path) if config_path.exists() else {}
79
86
  mcp = existing.setdefault("mcp", {})
80
- if name in mcp:
87
+ if name in mcp and not force:
81
88
  info(f"MCP server [accent]{name}[/accent] already in OpenCode global; skipping")
89
+ elif name in mcp and mcp[name] == server_config:
90
+ info(f"MCP server [accent]{name}[/accent] already matches in OpenCode global; unchanged")
82
91
  else:
92
+ replacing = name in mcp
83
93
  mcp[name] = server_config
84
94
  header = "// OpenCode global configuration generated by evo"
85
95
  save_jsonc(config_path, existing, header=header)
86
- success(f"Added [accent]{name}[/accent] to OpenCode global config")
96
+ verb = "Replaced" if replacing else "Added"
97
+ success(f"{verb} [accent]{name}[/accent] in OpenCode global config")
87
98
  info(f"Config: [accent]{config_path}[/accent]")
88
99
 
89
100
  if project:
@@ -91,7 +102,7 @@ def add_to_opencode(name, spec, project):
91
102
  proj_path = target / "opencode.json"
92
103
  proj_existing = load_jsonc(proj_path) if proj_path.exists() else {}
93
104
  proj_mcp = proj_existing.setdefault("mcp", {})
94
- if name in proj_mcp:
105
+ if name in proj_mcp and not force:
95
106
  info(f"MCP server [accent]{name}[/accent] already in project config; skipping")
96
107
  else:
97
108
  proj_mcp[name] = server_config
@@ -179,12 +190,17 @@ def list_servers():
179
190
  )
180
191
  @click.option("--claude-only", is_flag=True, help="Only configure Claude Code.")
181
192
  @click.option("--opencode-only", is_flag=True, help="Only configure OpenCode.")
193
+ @click.option(
194
+ "--force",
195
+ is_flag=True,
196
+ help="Replace an existing entry instead of leaving it alone (use when --env or the command changed).",
197
+ )
182
198
  @click.option(
183
199
  "--project",
184
200
  type=click.Path(file_okay=False, dir_okay=True),
185
201
  help="Also write into a project-level opencode.json at this directory.",
186
202
  )
187
- def add_server(name, url, command, env, transport, scope, claude_only, opencode_only, project):
203
+ def add_server(name, url, command, env, transport, scope, claude_only, opencode_only, force, project):
188
204
  """Add an MCP server to Claude Code and OpenCode.
189
205
 
190
206
  NAME is a key from the library (`evo mcp list`), or any label when paired
@@ -218,9 +234,9 @@ def add_server(name, url, command, env, transport, scope, claude_only, opencode_
218
234
 
219
235
  did_claude = did_opencode = False
220
236
  if not opencode_only:
221
- did_claude = add_to_claude(name, spec, scope)
237
+ did_claude = add_to_claude(name, spec, scope, force=force)
222
238
  if not claude_only:
223
- did_opencode = add_to_opencode(name, spec, project)
239
+ did_opencode = add_to_opencode(name, spec, project, force=force)
224
240
 
225
241
  console.print()
226
242
  targets = []
@@ -42,14 +42,19 @@ def is_windows():
42
42
 
43
43
 
44
44
  def get_global_config_dir():
45
- home = Path.home()
46
- if is_windows():
47
- return home / "AppData" / "Roaming" / "opencode"
48
- return home / ".config" / "opencode"
45
+ # OpenCode reads ~/.config/opencode on every platform, Windows included - it does
46
+ # not follow the %APPDATA% convention. Writing to AppData produced a file that
47
+ # `opencode mcp list` never saw, so every server evo "added" was silently inert.
48
+ return Path.home() / ".config" / "opencode"
49
49
 
50
50
 
51
51
  def get_global_config_path():
52
- return get_global_config_dir() / "opencode.jsonc"
52
+ directory = get_global_config_dir()
53
+ for name in ("opencode.jsonc", "opencode.json"):
54
+ candidate = directory / name
55
+ if candidate.exists():
56
+ return candidate
57
+ return directory / "opencode.jsonc"
53
58
 
54
59
 
55
60
  def ensure_node_installed():
@@ -168,7 +168,7 @@ def speak(
168
168
  if not quiet and not to_stdout:
169
169
  info(
170
170
  f"Provider [accent]{resolved}[/accent], voice "
171
- f"[accent]{voice or core.default_voice(resolved)}[/accent], "
171
+ f"[accent]{voice or core.default_voice_for(resolved)}[/accent], "
172
172
  f"{len(body.strip())} characters (limit {chunks}/request)"
173
173
  )
174
174
 
@@ -3,6 +3,7 @@ from evo_cli.tts.core import (
3
3
  PROVIDERS,
4
4
  chunk_limit,
5
5
  default_voice,
6
+ default_voice_for,
6
7
  list_voices,
7
8
  resolve_provider,
8
9
  supported_formats,
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "TtsError",
19
20
  "chunk_limit",
20
21
  "default_voice",
22
+ "default_voice_for",
21
23
  "list_voices",
22
24
  "play",
23
25
  "player_hint",
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from concurrent.futures import ThreadPoolExecutor, as_completed
2
3
 
3
4
  from evo_cli.tts import openai as openai_tts
@@ -15,6 +16,11 @@ def resolve_provider(provider):
15
16
  if provider in PROVIDERS:
16
17
  return provider
17
18
  if provider in (None, "", "auto"):
19
+ preferred = (os.environ.get("EVO_TTS_PROVIDER") or "").strip().lower()
20
+ if preferred in PROVIDERS:
21
+ return preferred
22
+ if preferred and preferred != "auto":
23
+ raise TtsError(f"EVO_TTS_PROVIDER is set to '{preferred}', which is not one of: {', '.join(PROVIDERS)}")
18
24
  if has_vbee_credentials():
19
25
  return "vbee"
20
26
  if has_openai_credentials():
@@ -27,6 +33,14 @@ def resolve_provider(provider):
27
33
  raise TtsError(f"unknown provider '{provider}' (expected one of: {', '.join(PROVIDERS)}, auto)")
28
34
 
29
35
 
36
+ def default_voice_for(provider):
37
+ # Provider-scoped first, so a machine configured for both does not hand a Vbee
38
+ # voice code to OpenAI the moment EVO_TTS_PROVIDER flips.
39
+ scoped = (os.environ.get(f"EVO_TTS_VOICE_{provider.upper()}") or "").strip()
40
+ shared = (os.environ.get("EVO_TTS_VOICE") or "").strip()
41
+ return scoped or shared or default_voice(provider)
42
+
43
+
30
44
  def default_voice(provider):
31
45
  return vbee.DEFAULT_VOICE if provider == "vbee" else openai_tts.DEFAULT_VOICE
32
46
 
@@ -81,6 +95,7 @@ def synthesize(
81
95
  on_poll=None,
82
96
  ):
83
97
  provider = resolve_provider(provider)
98
+ voice = voice or default_voice_for(provider)
84
99
  if mode not in MODES:
85
100
  raise TtsError(f"unknown mode '{mode}' (expected one of: {', '.join(MODES)})")
86
101
  text = (text or "").strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -214,8 +214,19 @@ evo cred add vbee.token --from-stdin # JWT from the same app page
214
214
  evo cred add openai_api_key --from-stdin
215
215
  ```
216
216
 
217
- `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
218
- (the default) picks Vbee when its credentials exist, otherwise OpenAI.
217
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set.
218
+
219
+ `--provider auto` (the default) resolves to `EVO_TTS_PROVIDER` when that is set, and otherwise to
220
+ whichever provider has credentials. Pick a machine default once:
221
+
222
+ ```bash
223
+ export EVO_TTS_PROVIDER=openai # what `auto` means here
224
+ export EVO_TTS_VOICE_OPENAI=nova # default voice for that provider only
225
+ ```
226
+
227
+ Prefer the provider-scoped `EVO_TTS_VOICE_OPENAI` / `EVO_TTS_VOICE_VBEE` over a bare
228
+ `EVO_TTS_VOICE`: a shared value breaks as soon as you pass `--provider vbee`, because an OpenAI
229
+ voice name is not a Vbee voice code.
219
230
 
220
231
  Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
221
232
  falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
@@ -2,7 +2,7 @@ import pytest
2
2
  from click.testing import CliRunner
3
3
 
4
4
  from evo_cli.cli import cli
5
- from evo_cli.commands.mcp import resolve_spec, to_opencode_config
5
+ from evo_cli.commands.mcp import add_to_claude, resolve_spec, to_opencode_config
6
6
  from evo_cli.commands.opencode import load_jsonc
7
7
 
8
8
 
@@ -128,3 +128,47 @@ def test_add_rejects_both_only_flags(runner, tmp_path, monkeypatch):
128
128
  )
129
129
  assert result.exit_code == 0
130
130
  assert "mutually exclusive" in result.output
131
+
132
+
133
+ def test_claude_add_puts_the_name_before_the_variadic_env_flag(monkeypatch):
134
+ captured = []
135
+ monkeypatch.setattr("evo_cli.commands.mcp.shutil.which", lambda name: f"/usr/bin/{name}")
136
+ monkeypatch.setattr("evo_cli.commands.mcp.claude_has_server", lambda name: False)
137
+ monkeypatch.setattr("evo_cli.commands.mcp.run_command", lambda cmd, **kw: captured.append(cmd))
138
+
139
+ spec = {"transport": "stdio", "command": ["uv", "run", "server.py"], "env": {"A": "1", "B": "2"}}
140
+ add_to_claude("evo-tts", spec, "user")
141
+
142
+ cmd = captured[0]
143
+ # `claude mcp add` declares -e as variadic, so a name after it is swallowed as
144
+ # another KEY=value and the real CLI errors out.
145
+ assert cmd.index("evo-tts") < cmd.index("-e")
146
+ assert cmd[cmd.index("--") + 1 :] == ["uv", "run", "server.py"]
147
+ assert "A=1" in cmd and "B=2" in cmd
148
+
149
+
150
+ def test_opencode_add_leaves_an_existing_entry_alone_without_force(runner, tmp_path, monkeypatch):
151
+ global_path = tmp_path / "global.jsonc"
152
+ monkeypatch.setattr("evo_cli.commands.mcp.get_global_config_path", lambda: global_path)
153
+
154
+ runner.invoke(cli, ["mcp", "add", "srv", "--opencode-only", "--command", "old cmd"])
155
+ result = runner.invoke(cli, ["mcp", "add", "srv", "--opencode-only", "--command", "new cmd"])
156
+
157
+ assert result.exit_code == 0
158
+ assert load_jsonc(global_path)["mcp"]["srv"]["command"] == ["old", "cmd"]
159
+
160
+
161
+ def test_opencode_add_force_replaces_the_entry(runner, tmp_path, monkeypatch):
162
+ global_path = tmp_path / "global.jsonc"
163
+ monkeypatch.setattr("evo_cli.commands.mcp.get_global_config_path", lambda: global_path)
164
+
165
+ runner.invoke(cli, ["mcp", "add", "srv", "--opencode-only", "--command", "old cmd"])
166
+ result = runner.invoke(
167
+ cli,
168
+ ["mcp", "add", "srv", "--opencode-only", "--force", "--env", "K=v", "--command", "new cmd"],
169
+ )
170
+
171
+ assert result.exit_code == 0
172
+ entry = load_jsonc(global_path)["mcp"]["srv"]
173
+ assert entry["command"] == ["new", "cmd"]
174
+ assert entry["environment"] == {"K": "v"}
@@ -5,6 +5,7 @@ import pytest
5
5
  from click.testing import CliRunner
6
6
 
7
7
  from evo_cli.cli import cli
8
+ from evo_cli.commands import opencode
8
9
  from evo_cli.commands.opencode import (
9
10
  DEFAULT_MCP_SERVERS,
10
11
  EXA_ENV_VAR,
@@ -209,3 +210,34 @@ def test_setup_opencode_global_only(runner, tmp_path, monkeypatch):
209
210
  result = runner.invoke(cli, ["setup", "opencode", "--global-only", "--project", str(project)])
210
211
  assert result.exit_code == 0
211
212
  assert not (project / "opencode.json").exists()
213
+
214
+
215
+ def test_global_config_dir_is_xdg_style_on_every_platform(monkeypatch, tmp_path):
216
+ monkeypatch.setattr(opencode.Path, "home", classmethod(lambda cls: tmp_path))
217
+ for system in ("Windows", "Linux", "Darwin"):
218
+ monkeypatch.setattr(opencode.platform, "system", lambda system=system: system)
219
+ assert opencode.get_global_config_dir() == tmp_path / ".config" / "opencode"
220
+
221
+
222
+ def test_global_config_path_reuses_an_existing_json_file(monkeypatch, tmp_path):
223
+ monkeypatch.setattr(opencode.Path, "home", classmethod(lambda cls: tmp_path))
224
+ directory = tmp_path / ".config" / "opencode"
225
+ directory.mkdir(parents=True)
226
+ existing = directory / "opencode.json"
227
+ existing.write_text("{}", encoding="utf-8")
228
+ assert opencode.get_global_config_path() == existing
229
+
230
+
231
+ def test_global_config_path_prefers_jsonc_when_both_exist(monkeypatch, tmp_path):
232
+ monkeypatch.setattr(opencode.Path, "home", classmethod(lambda cls: tmp_path))
233
+ directory = tmp_path / ".config" / "opencode"
234
+ directory.mkdir(parents=True)
235
+ (directory / "opencode.json").write_text("{}", encoding="utf-8")
236
+ (directory / "opencode.jsonc").write_text("{}", encoding="utf-8")
237
+ assert opencode.get_global_config_path() == directory / "opencode.jsonc"
238
+
239
+
240
+ def test_global_config_path_defaults_to_jsonc_when_nothing_exists(monkeypatch, tmp_path):
241
+ monkeypatch.setattr(opencode.Path, "home", classmethod(lambda cls: tmp_path))
242
+ expected = tmp_path / ".config" / "opencode" / "opencode.jsonc"
243
+ assert opencode.get_global_config_path() == expected
@@ -9,6 +9,24 @@ from evo_cli.tts import core, vbee
9
9
  from evo_cli.tts.chunking import join_audio, split_text
10
10
  from evo_cli.tts.errors import TtsError
11
11
 
12
+ EVO_TTS_ENV_VARS = (
13
+ "EVO_TTS_PROVIDER",
14
+ "EVO_TTS_VOICE",
15
+ "EVO_TTS_VOICE_OPENAI",
16
+ "EVO_TTS_VOICE_VBEE",
17
+ "EVO_TTS_SPEED",
18
+ "EVO_TTS_DIR",
19
+ )
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def clean_tts_env(monkeypatch):
24
+ # These are set for real on machines that picked a default provider, and they
25
+ # would otherwise decide the outcome of the auto-detection tests. CI has none of
26
+ # them set, so without this the suite passes there and fails on a real desktop.
27
+ for name in EVO_TTS_ENV_VARS:
28
+ monkeypatch.delenv(name, raising=False)
29
+
12
30
 
13
31
  @pytest.fixture
14
32
  def runner():
@@ -258,3 +276,54 @@ def test_batch_exits_nonzero_when_an_item_fails(runner, monkeypatch, tmp_path):
258
276
  result = runner.invoke(cli, ["tts", "batch", "-t", "xin chào", "-o", str(tmp_path / "out")])
259
277
  assert result.exit_code == 1
260
278
  assert "nope" in result.output
279
+
280
+
281
+ def test_resolve_provider_auto_follows_the_env_override(monkeypatch):
282
+ monkeypatch.setenv("EVO_TTS_PROVIDER", "openai")
283
+ monkeypatch.setattr(core, "has_vbee_credentials", lambda: True)
284
+ assert core.resolve_provider("auto") == "openai"
285
+
286
+
287
+ def test_env_override_does_not_beat_an_explicit_provider(monkeypatch):
288
+ monkeypatch.setenv("EVO_TTS_PROVIDER", "openai")
289
+ assert core.resolve_provider("vbee") == "vbee"
290
+
291
+
292
+ def test_resolve_provider_rejects_a_bogus_env_override(monkeypatch):
293
+ monkeypatch.setenv("EVO_TTS_PROVIDER", "elevenlabs")
294
+ with pytest.raises(TtsError, match="EVO_TTS_PROVIDER"):
295
+ core.resolve_provider("auto")
296
+
297
+
298
+ def test_default_voice_for_falls_back_to_the_provider_default(monkeypatch):
299
+ monkeypatch.delenv("EVO_TTS_VOICE", raising=False)
300
+ monkeypatch.delenv("EVO_TTS_VOICE_OPENAI", raising=False)
301
+ assert core.default_voice_for("openai") == core.openai_tts.DEFAULT_VOICE
302
+ assert core.default_voice_for("vbee") == vbee.DEFAULT_VOICE
303
+
304
+
305
+ def test_scoped_voice_env_wins_over_the_shared_one(monkeypatch):
306
+ monkeypatch.setenv("EVO_TTS_VOICE", "shared")
307
+ monkeypatch.setenv("EVO_TTS_VOICE_OPENAI", "nova")
308
+ assert core.default_voice_for("openai") == "nova"
309
+ assert core.default_voice_for("vbee") == "shared"
310
+
311
+
312
+ def test_scoped_voice_does_not_leak_across_providers(monkeypatch):
313
+ monkeypatch.delenv("EVO_TTS_VOICE", raising=False)
314
+ monkeypatch.setenv("EVO_TTS_VOICE_OPENAI", "nova")
315
+ assert core.default_voice_for("vbee") == vbee.DEFAULT_VOICE
316
+
317
+
318
+ def test_synthesize_applies_the_env_voice(monkeypatch):
319
+ monkeypatch.delenv("EVO_TTS_VOICE", raising=False)
320
+ monkeypatch.setenv("EVO_TTS_VOICE_OPENAI", "nova")
321
+ seen = {}
322
+
323
+ def fake_synthesize(text, **kwargs):
324
+ seen["voice"] = kwargs["voice"]
325
+ return b"x"
326
+
327
+ monkeypatch.setattr(core.openai_tts, "synthesize", fake_synthesize)
328
+ core.synthesize("xin chào", provider="openai")
329
+ assert seen["voice"] == "nova"
@@ -1 +0,0 @@
1
- 0.13.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes