evo-cli 0.7.0__tar.gz → 0.8.1__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 (42) hide show
  1. {evo_cli-0.7.0 → evo_cli-0.8.1}/PKG-INFO +1 -1
  2. evo_cli-0.8.1/evo_cli/VERSION +1 -0
  3. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/cli.py +11 -2
  4. evo_cli-0.8.1/evo_cli/commands/opencode.py +339 -0
  5. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/console.py +23 -9
  6. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/SOURCES.txt +3 -0
  8. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/test_cli.py +12 -1
  9. evo_cli-0.8.1/tests/test_console.py +32 -0
  10. evo_cli-0.8.1/tests/test_opencode.py +137 -0
  11. evo_cli-0.7.0/evo_cli/VERSION +0 -1
  12. {evo_cli-0.7.0 → evo_cli-0.8.1}/Containerfile +0 -0
  13. {evo_cli-0.7.0 → evo_cli-0.8.1}/HISTORY.md +0 -0
  14. {evo_cli-0.7.0 → evo_cli-0.8.1}/LICENSE +0 -0
  15. {evo_cli-0.7.0 → evo_cli-0.8.1}/MANIFEST.in +0 -0
  16. {evo_cli-0.7.0 → evo_cli-0.8.1}/README.md +0 -0
  17. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/__init__.py +0 -0
  18. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/__main__.py +0 -0
  19. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/base.py +0 -0
  20. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/__init__.py +0 -0
  21. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/cloudflare.py +0 -0
  22. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/fix_claude.py +0 -0
  23. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/gdrive.py +0 -0
  24. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/localproxy.py +0 -0
  25. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/miniconda.py +0 -0
  26. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/netcheck.py +0 -0
  27. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/plantuml.py +0 -0
  28. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/site2s.py +0 -0
  29. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/ssh.py +0 -0
  30. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/sysmon.py +0 -0
  31. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli/commands/wifi.py +0 -0
  32. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  33. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/entry_points.txt +0 -0
  34. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/requires.txt +0 -0
  35. {evo_cli-0.7.0 → evo_cli-0.8.1}/evo_cli.egg-info/top_level.txt +0 -0
  36. {evo_cli-0.7.0 → evo_cli-0.8.1}/pyproject.toml +0 -0
  37. {evo_cli-0.7.0 → evo_cli-0.8.1}/setup.cfg +0 -0
  38. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/__init__.py +0 -0
  39. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/test_fix_claude.py +0 -0
  40. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/test_plantuml.py +0 -0
  41. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/test_sysmon.py +0 -0
  42. {evo_cli-0.7.0 → evo_cli-0.8.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.7.0
3
+ Version: 0.8.1
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
@@ -0,0 +1 @@
1
+ 0.8.1
@@ -7,12 +7,18 @@ from evo_cli.commands.gdrive import gdrive
7
7
  from evo_cli.commands.localproxy import localproxy
8
8
  from evo_cli.commands.miniconda import miniconda
9
9
  from evo_cli.commands.netcheck import netcheck
10
+ from evo_cli.commands.opencode import setup_opencode
10
11
  from evo_cli.commands.plantuml import plantuml
11
12
  from evo_cli.commands.site2s import site2s
12
13
  from evo_cli.commands.ssh import setupssh
13
14
  from evo_cli.commands.sysmon import sysmon
14
15
  from evo_cli.commands.wifi import wifi
15
16
 
17
+
18
+ @click.group("setup", help="Set up development tools and environments.")
19
+ def setup_group():
20
+ """Commands for bootstrapping tools on a fresh machine."""
21
+
16
22
  click.rich_click.USE_MARKDOWN = True
17
23
  click.rich_click.SHOW_ARGUMENTS = True
18
24
  click.rich_click.STYLE_OPTIONS_TABLE_BOX = "SIMPLE"
@@ -34,8 +40,11 @@ def cli():
34
40
  """
35
41
 
36
42
 
37
- cli.add_command(setupssh)
38
- cli.add_command(miniconda)
43
+ setup_group.add_command(setup_opencode)
44
+ setup_group.add_command(miniconda)
45
+ setup_group.add_command(setupssh)
46
+
47
+ cli.add_command(setup_group)
39
48
  cli.add_command(cfssh)
40
49
  cli.add_command(f_claude)
41
50
  cli.add_command(gdrive)
@@ -0,0 +1,339 @@
1
+ """Install and configure OpenCode with common MCP servers."""
2
+
3
+ import json
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import rich_click as click
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+
13
+ from evo_cli.console import console, error, info, run_command, step, success, warning
14
+
15
+ EPILOG = Text.from_markup(
16
+ "[bold]Examples[/bold]\n\n"
17
+ " [cyan]evo setup opencode[/cyan]\n"
18
+ " [cyan]evo setup opencode --global-only[/cyan]\n"
19
+ " [cyan]evo setup opencode --project .[/cyan]"
20
+ )
21
+
22
+ DEFAULT_MCP_SERVERS = {
23
+ "google-search": {
24
+ "type": "local",
25
+ "command": ["npx", "-y", "@mcp-server/google-search-mcp@latest"],
26
+ "enabled": True,
27
+ },
28
+ "playwright": {
29
+ "type": "local",
30
+ "command": ["npx", "-y", "@playwright/mcp@latest", "--headless"],
31
+ "enabled": True,
32
+ },
33
+ }
34
+
35
+
36
+ def is_windows():
37
+ return platform.system() == "Windows"
38
+
39
+
40
+ def get_global_config_dir():
41
+ home = Path.home()
42
+ if is_windows():
43
+ return home / "AppData" / "Roaming" / "opencode"
44
+ return home / ".config" / "opencode"
45
+
46
+
47
+ def get_global_config_path():
48
+ return get_global_config_dir() / "opencode.jsonc"
49
+
50
+
51
+ def ensure_node_installed():
52
+ """Ensure Node.js and npm are available; return their paths."""
53
+ node_cmd = shutil.which("node")
54
+ npm_cmd = shutil.which("npm")
55
+ npx_cmd = shutil.which("npx")
56
+
57
+ if node_cmd and npm_cmd and npx_cmd:
58
+ result = subprocess.run(["node", "--version"], capture_output=True, text=True, check=True)
59
+ info(f"Node.js found: {result.stdout.strip()}")
60
+ return node_cmd, npm_cmd, npx_cmd
61
+
62
+ info("Node.js/npm not found. Installing via package manager...")
63
+ system = platform.system()
64
+ if system == "Linux":
65
+ try:
66
+ if shutil.which("apt-get"):
67
+ run_command(
68
+ ["sudo", "apt-get", "update"],
69
+ status="Updating apt package lists",
70
+ )
71
+ run_command(
72
+ ["sudo", "apt-get", "install", "-y", "nodejs", "npm"],
73
+ status="Installing nodejs and npm",
74
+ )
75
+ elif shutil.which("dnf"):
76
+ run_command(
77
+ ["sudo", "dnf", "install", "-y", "nodejs", "npm"],
78
+ status="Installing nodejs and npm",
79
+ )
80
+ elif shutil.which("pacman"):
81
+ run_command(
82
+ ["sudo", "pacman", "-Sy", "--noconfirm", "nodejs", "npm"],
83
+ status="Installing nodejs and npm",
84
+ )
85
+ elif shutil.which("apk"):
86
+ run_command(
87
+ ["sudo", "apk", "add", "nodejs", "npm"],
88
+ status="Installing nodejs and npm",
89
+ )
90
+ else:
91
+ raise RuntimeError("No supported package manager found (apt-get, dnf, pacman, apk)")
92
+ except Exception as exc:
93
+ error(f"Could not install Node.js automatically: {exc}")
94
+ info("Please install Node.js 18+ manually from https://nodejs.org/")
95
+ raise
96
+ elif system == "Darwin":
97
+ if shutil.which("brew"):
98
+ run_command(["brew", "install", "node"], status="Installing Node.js via Homebrew")
99
+ else:
100
+ raise RuntimeError("Homebrew not found. Please install Node.js 18+ manually.")
101
+ elif system == "Windows":
102
+ raise RuntimeError("Please install Node.js 18+ manually from https://nodejs.org/")
103
+ else:
104
+ raise RuntimeError(f"Unsupported platform: {system}")
105
+
106
+ node_cmd = shutil.which("node")
107
+ npm_cmd = shutil.which("npm")
108
+ npx_cmd = shutil.which("npx")
109
+ if not (node_cmd and npm_cmd and npx_cmd):
110
+ raise RuntimeError("Node.js installation succeeded but binaries are not on PATH")
111
+ return node_cmd, npm_cmd, npx_cmd
112
+
113
+
114
+ def install_mcp_servers():
115
+ """Pre-fetch MCP server npm packages into the npx cache.
116
+
117
+ MCP servers speak JSON-RPC over stdio, so most do not implement a real
118
+ ``--version`` flag - they simply start up and block reading stdin. Run with
119
+ stdin detached (DEVNULL) so a server that ignores ``--version`` sees EOF and
120
+ exits right away instead of hanging on the terminal, and cap each call with a
121
+ timeout as a safety net. ``check=False`` keeps a non-zero exit from aborting
122
+ the whole setup; the verify step confirms the servers actually work.
123
+ """
124
+ step("Installing MCP servers")
125
+ servers = [
126
+ "@mcp-server/google-search-mcp@latest",
127
+ "@playwright/mcp@latest",
128
+ ]
129
+ for server in servers:
130
+ info(f"Fetching {server}")
131
+ run_command(
132
+ ["npx", "-y", server, "--version"],
133
+ status=f"Fetching {server}",
134
+ stdin=subprocess.DEVNULL,
135
+ timeout=180,
136
+ check=False,
137
+ )
138
+ success("MCP server packages ready")
139
+
140
+
141
+ def install_playwright_browsers():
142
+ """Install Playwright Chromium browser binaries."""
143
+ step("Installing Playwright browsers")
144
+ try:
145
+ run_command(
146
+ ["npx", "playwright", "install", "chromium"],
147
+ status="Downloading Chromium for Playwright",
148
+ stdin=subprocess.DEVNULL,
149
+ timeout=600,
150
+ )
151
+ success("Playwright Chromium installed")
152
+ except Exception as exc:
153
+ warning(f"Playwright browser install failed: {exc}")
154
+ info("You can retry later with: npx playwright install chromium")
155
+
156
+
157
+ def load_jsonc(path):
158
+ """Load a JSONC file, stripping // comments.
159
+
160
+ Be careful not to truncate URLs that contain '://' inside a JSON string.
161
+ """
162
+ path = Path(path)
163
+ if not path.exists():
164
+ return {}
165
+ text = path.read_text(encoding="utf-8")
166
+ cleaned_lines = []
167
+ for line in text.splitlines():
168
+ new_line = []
169
+ in_string = False
170
+ escape = False
171
+ for idx, ch in enumerate(line):
172
+ if escape:
173
+ new_line.append(ch)
174
+ escape = False
175
+ continue
176
+ if ch == "\\":
177
+ new_line.append(ch)
178
+ escape = True
179
+ continue
180
+ if ch == '"':
181
+ in_string = not in_string
182
+ new_line.append(ch)
183
+ continue
184
+ if not in_string and ch == "/" and idx + 1 < len(line) and line[idx + 1] == "/":
185
+ break
186
+ new_line.append(ch)
187
+ stripped = "".join(new_line).strip()
188
+ if stripped.startswith("//"):
189
+ continue
190
+ cleaned_lines.append("".join(new_line))
191
+ cleaned = "\n".join(cleaned_lines)
192
+ if not cleaned.strip():
193
+ return {}
194
+ try:
195
+ return json.loads(cleaned)
196
+ except json.JSONDecodeError as exc:
197
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
198
+
199
+
200
+ def save_jsonc(path, data, header=None):
201
+ """Save data as pretty-printed JSONC."""
202
+ path.parent.mkdir(parents=True, exist_ok=True)
203
+ content = json.dumps(data, indent=2, ensure_ascii=False)
204
+ if header:
205
+ content = header + "\n" + content
206
+ path.write_text(content + "\n", encoding="utf-8")
207
+
208
+
209
+ def merge_mcp_config(existing, new_servers):
210
+ """Merge new MCP servers into existing config, preserving user values."""
211
+ result = dict(existing)
212
+ mcp = result.setdefault("mcp", {})
213
+ for name, config in new_servers.items():
214
+ if name in mcp:
215
+ info(f"MCP server [accent]{name}[/accent] already configured; skipping")
216
+ continue
217
+ mcp[name] = dict(config)
218
+ success(f"Added MCP server [accent]{name}[/accent]")
219
+ return result
220
+
221
+
222
+ def configure_opencode_global():
223
+ """Write/update the global OpenCode config with MCP servers."""
224
+ step("Configuring global OpenCode")
225
+ config_path = get_global_config_path()
226
+ config_dir = config_path.parent
227
+ config_dir.mkdir(parents=True, exist_ok=True)
228
+
229
+ existing = load_jsonc(config_path) if config_path.exists() else {}
230
+ merged = merge_mcp_config(existing, DEFAULT_MCP_SERVERS)
231
+
232
+ header = "// OpenCode global configuration generated by evo setup opencode"
233
+ save_jsonc(config_path, merged, header=header)
234
+ success(f"Global config written to [accent]{config_path}[/accent]")
235
+ return config_path
236
+
237
+
238
+ def configure_opencode_project(project_path):
239
+ """Write/update a project-level OpenCode config with MCP servers."""
240
+ step("Configuring project OpenCode")
241
+ project_dir = Path(project_path).resolve()
242
+ config_path = project_dir / "opencode.json"
243
+
244
+ existing = load_jsonc(config_path) if config_path.exists() else {}
245
+ merged = merge_mcp_config(existing, DEFAULT_MCP_SERVERS)
246
+
247
+ header = "// OpenCode project configuration generated by evo setup opencode"
248
+ save_jsonc(config_path, merged, header=header)
249
+ success(f"Project config written to [accent]{config_path}[/accent]")
250
+ return config_path
251
+
252
+
253
+ def verify_mcp_servers():
254
+ """Run a basic JSON-RPC initialize check against installed MCP servers."""
255
+ step("Verifying MCP servers")
256
+ init_message = (
257
+ '{"jsonrpc":"2.0","id":1,"method":"initialize",'
258
+ '"params":{"protocolVersion":"2024-11-05","capabilities":{},'
259
+ '"clientInfo":{"name":"evo-cli","version":"1.0"}}}'
260
+ )
261
+ checks = [
262
+ ("playwright", ["npx", "-y", "@playwright/mcp@latest", "--headless"]),
263
+ ("google-search", ["npx", "-y", "@mcp-server/google-search-mcp@latest"]),
264
+ ]
265
+ for name, cmd in checks:
266
+ try:
267
+ result = subprocess.run(
268
+ cmd,
269
+ input=init_message,
270
+ capture_output=True,
271
+ text=True,
272
+ timeout=30,
273
+ )
274
+ if result.returncode == 0 and '"jsonrpc":"2.0"' in result.stdout:
275
+ success(f"MCP server [accent]{name}[/accent] responded to initialize")
276
+ else:
277
+ warning(f"MCP server [accent]{name}[/accent] did not respond as expected")
278
+ except Exception as exc:
279
+ warning(f"Could not verify MCP server [accent]{name}[/accent]: {exc}")
280
+
281
+
282
+ def run_setup_opencode(global_only, project):
283
+ step("evo setup opencode")
284
+
285
+ try:
286
+ ensure_node_installed()
287
+ except Exception as exc:
288
+ error(str(exc))
289
+ return
290
+
291
+ install_mcp_servers()
292
+ install_playwright_browsers()
293
+
294
+ global_path = configure_opencode_global()
295
+ project_path = None
296
+ if not global_only:
297
+ target = project or Path.cwd()
298
+ project_path = configure_opencode_project(target)
299
+
300
+ verify_mcp_servers()
301
+
302
+ console.print()
303
+ paths_text = f"Global: [accent]{global_path}[/accent]"
304
+ if project_path:
305
+ paths_text += f"\nProject: [accent]{project_path}[/accent]"
306
+
307
+ console.print(
308
+ Panel(
309
+ f"OpenCode configured with google-search and playwright MCP servers.\n\n{paths_text}\n\n"
310
+ "Restart OpenCode to load the new MCP tools.",
311
+ title="setup opencode complete",
312
+ border_style="success",
313
+ expand=False,
314
+ )
315
+ )
316
+
317
+
318
+ @click.command("opencode", epilog=EPILOG)
319
+ @click.option(
320
+ "--global-only",
321
+ is_flag=True,
322
+ help="Only update the global OpenCode config; skip project-level config.",
323
+ )
324
+ @click.option(
325
+ "--project",
326
+ type=click.Path(file_okay=False, dir_okay=True, writable=True),
327
+ help="Project directory to write opencode.json into. Defaults to the current directory.",
328
+ )
329
+ def setup_opencode(global_only, project):
330
+ """Install Node.js (if needed), OpenCode MCP servers, and write config files.
331
+
332
+ This command bootstraps a fresh machine with the same OpenCode + MCP setup
333
+ used across devices. It installs the google-search and playwright MCP servers,
334
+ downloads the Playwright Chromium browser, and writes both global
335
+ (~/.config/opencode/opencode.jsonc) and project-level (opencode.json) configs.
336
+
337
+ No credentials are bundled; only public MCP server references are added.
338
+ """
339
+ run_setup_opencode(global_only, project)
@@ -52,17 +52,31 @@ def step(title):
52
52
  console.rule(f"[step]{title}[/step]", style="step", align="left")
53
53
 
54
54
 
55
- def run_command(cmd, capture=False, check=True, input_text=None, status=None):
55
+ def run_command(cmd, capture=False, check=True, input_text=None, status=None, timeout=None, stdin=None):
56
56
  cmd = [str(part) for part in cmd]
57
57
  console.print(f"[cmd]$ {' '.join(cmd)}[/cmd]")
58
- if status:
59
- with console.status(f"[info]{status}[/info]", spinner="dots"):
60
- result = subprocess.run(cmd, capture_output=True, text=True, input=input_text)
61
- output = (result.stdout or "").strip()
62
- if output:
63
- console.print(output)
64
- else:
65
- result = subprocess.run(cmd, capture_output=capture, text=True, input=input_text)
58
+
59
+ run_kwargs = {"text": True, "input": input_text, "timeout": timeout}
60
+ # subprocess.run rejects passing both `input` and `stdin`. Only attach an
61
+ # explicit stdin (e.g. DEVNULL) when we are not feeding input, so callers can
62
+ # detach a child from the terminal and avoid it blocking on an inherited TTY.
63
+ if input_text is None and stdin is not None:
64
+ run_kwargs["stdin"] = stdin
65
+
66
+ try:
67
+ if status:
68
+ with console.status(f"[info]{status}[/info]", spinner="dots"):
69
+ result = subprocess.run(cmd, capture_output=True, **run_kwargs)
70
+ output = (result.stdout or "").strip()
71
+ if output:
72
+ console.print(output)
73
+ else:
74
+ result = subprocess.run(cmd, capture_output=capture, **run_kwargs)
75
+ except subprocess.TimeoutExpired as exc:
76
+ console.print(f"[error]command timed out after {timeout}s: {' '.join(cmd)}[/error]")
77
+ if check:
78
+ raise CommandError(f"command timed out after {timeout}s: {' '.join(cmd)}") from exc
79
+ return exc
66
80
 
67
81
  if result.returncode != 0:
68
82
  message = (result.stderr or "").strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.7.0
3
+ Version: 0.8.1
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
@@ -23,6 +23,7 @@ evo_cli/commands/gdrive.py
23
23
  evo_cli/commands/localproxy.py
24
24
  evo_cli/commands/miniconda.py
25
25
  evo_cli/commands/netcheck.py
26
+ evo_cli/commands/opencode.py
26
27
  evo_cli/commands/plantuml.py
27
28
  evo_cli/commands/site2s.py
28
29
  evo_cli/commands/ssh.py
@@ -30,7 +31,9 @@ evo_cli/commands/sysmon.py
30
31
  evo_cli/commands/wifi.py
31
32
  tests/__init__.py
32
33
  tests/test_cli.py
34
+ tests/test_console.py
33
35
  tests/test_fix_claude.py
36
+ tests/test_opencode.py
34
37
  tests/test_plantuml.py
35
38
  tests/test_sysmon.py
36
39
  tests/test_wifi.py
@@ -4,10 +4,16 @@ from evo_cli.cli import cli
4
4
 
5
5
 
6
6
  def test_all_commands_are_registered():
7
- for name in ("setupssh", "miniconda", "cfssh", "f-claude"):
7
+ for name in ("setup", "cfssh", "f-claude"):
8
8
  assert name in cli.commands
9
9
 
10
10
 
11
+ def test_setup_subgroup_commands_are_registered():
12
+ setup_group = cli.commands["setup"]
13
+ for name in ("opencode", "miniconda", "setupssh"):
14
+ assert name in setup_group.commands
15
+
16
+
11
17
  def test_group_help_runs():
12
18
  result = CliRunner().invoke(cli, ["--help"])
13
19
  assert result.exit_code == 0
@@ -16,3 +22,8 @@ def test_group_help_runs():
16
22
  def test_fix_claude_help_runs():
17
23
  result = CliRunner().invoke(cli, ["f-claude", "--help"])
18
24
  assert result.exit_code == 0
25
+
26
+
27
+ def test_setup_opencode_help_runs():
28
+ result = CliRunner().invoke(cli, ["setup", "opencode", "--help"])
29
+ assert result.exit_code == 0
@@ -0,0 +1,32 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import pytest
5
+
6
+ from evo_cli.console import CommandError, run_command
7
+
8
+
9
+ def test_run_command_raises_on_timeout():
10
+ # A command that outlives the timeout must not hang; it should raise.
11
+ with pytest.raises(CommandError, match="timed out"):
12
+ run_command([sys.executable, "-c", "import time; time.sleep(5)"], timeout=1)
13
+
14
+
15
+ def test_run_command_timeout_without_check_returns_exception():
16
+ result = run_command(
17
+ [sys.executable, "-c", "import time; time.sleep(5)"],
18
+ timeout=1,
19
+ check=False,
20
+ )
21
+ assert isinstance(result, subprocess.TimeoutExpired)
22
+
23
+
24
+ def test_run_command_detaches_stdin_when_requested():
25
+ # With stdin=DEVNULL a child that reads stdin sees EOF immediately instead
26
+ # of blocking on an inherited terminal.
27
+ result = run_command(
28
+ [sys.executable, "-c", "import sys; sys.exit(0 if sys.stdin.read() == '' else 1)"],
29
+ stdin=subprocess.DEVNULL,
30
+ timeout=10,
31
+ )
32
+ assert result.returncode == 0
@@ -0,0 +1,137 @@
1
+ import json
2
+ import subprocess
3
+
4
+ import pytest
5
+ from click.testing import CliRunner
6
+
7
+ from evo_cli.cli import cli
8
+ from evo_cli.commands.opencode import (
9
+ DEFAULT_MCP_SERVERS,
10
+ configure_opencode_global,
11
+ configure_opencode_project,
12
+ install_mcp_servers,
13
+ load_jsonc,
14
+ merge_mcp_config,
15
+ save_jsonc,
16
+ )
17
+
18
+
19
+ @pytest.fixture
20
+ def runner():
21
+ return CliRunner()
22
+
23
+
24
+ def test_opencode_command_is_registered():
25
+ assert "setup" in cli.commands
26
+ assert "opencode" in cli.commands["setup"].commands
27
+
28
+
29
+ def test_opencode_help_runs(runner):
30
+ result = runner.invoke(cli, ["setup", "opencode", "--help"])
31
+ assert result.exit_code == 0
32
+ assert "Install Node.js" in result.output or "OpenCode" in result.output
33
+
34
+
35
+ def test_load_jsonc_ignores_comments(tmp_path):
36
+ path = tmp_path / "config.jsonc"
37
+ path.write_text("// header\n{\"a\": 1}\n// footer")
38
+ assert load_jsonc(path) == {"a": 1}
39
+
40
+
41
+ def test_load_jsonc_missing_file(tmp_path):
42
+ assert load_jsonc(tmp_path / "nope.jsonc") == {}
43
+
44
+
45
+ def test_save_and_load_jsonc(tmp_path):
46
+ path = tmp_path / "config.jsonc"
47
+ save_jsonc(path, {"foo": "bar"}, header="// test")
48
+ data = load_jsonc(path)
49
+ assert data == {"foo": "bar"}
50
+ assert path.read_text().startswith("// test")
51
+
52
+
53
+ def test_merge_mcp_config_preserves_existing():
54
+ existing = {"mcp": {"playwright": {"enabled": False}}}
55
+ merged = merge_mcp_config(existing, DEFAULT_MCP_SERVERS)
56
+ assert merged["mcp"]["playwright"]["enabled"] is False
57
+ assert merged["mcp"]["google-search"]["enabled"] is True
58
+
59
+
60
+ def test_configure_opencode_global(tmp_path, monkeypatch):
61
+ monkeypatch.setattr(
62
+ "evo_cli.commands.opencode.get_global_config_path",
63
+ lambda: tmp_path / "opencode" / "opencode.jsonc",
64
+ )
65
+ path = configure_opencode_global()
66
+ data = load_jsonc(path)
67
+ assert "mcp" in data
68
+ assert set(data["mcp"].keys()) == {"google-search", "playwright"}
69
+
70
+
71
+ def test_configure_opencode_project(tmp_path):
72
+ path = configure_opencode_project(tmp_path)
73
+ data = load_jsonc(path)
74
+ assert "mcp" in data
75
+ assert set(data["mcp"].keys()) == {"google-search", "playwright"}
76
+
77
+
78
+ def test_install_mcp_servers_detaches_stdin_and_sets_timeout(monkeypatch):
79
+ """Regression: MCP servers read stdin, so the fetch must not block on a TTY."""
80
+ calls = []
81
+
82
+ def fake_run_command(cmd, **kwargs):
83
+ calls.append((cmd, kwargs))
84
+
85
+ monkeypatch.setattr("evo_cli.commands.opencode.run_command", fake_run_command)
86
+ install_mcp_servers()
87
+
88
+ assert calls, "expected at least one MCP server fetch"
89
+ for cmd, kwargs in calls:
90
+ assert kwargs.get("stdin") is subprocess.DEVNULL
91
+ assert kwargs.get("timeout")
92
+ # A server that ignores --version must not abort the whole setup.
93
+ assert kwargs.get("check") is False
94
+
95
+
96
+ def test_setup_opencode_writes_global_and_project(runner, tmp_path, monkeypatch):
97
+ monkeypatch.setattr(
98
+ "evo_cli.commands.opencode.get_global_config_path",
99
+ lambda: tmp_path / "global.jsonc",
100
+ )
101
+ monkeypatch.setattr(
102
+ "evo_cli.commands.opencode.ensure_node_installed",
103
+ lambda: ("node", "npm", "npx"),
104
+ )
105
+ monkeypatch.setattr("evo_cli.commands.opencode.install_mcp_servers", lambda: None)
106
+ monkeypatch.setattr(
107
+ "evo_cli.commands.opencode.install_playwright_browsers", lambda: None
108
+ )
109
+ monkeypatch.setattr("evo_cli.commands.opencode.verify_mcp_servers", lambda: None)
110
+
111
+ result = runner.invoke(cli, ["setup", "opencode", "--project", str(tmp_path)])
112
+ assert result.exit_code == 0
113
+ assert (tmp_path / "opencode.json").exists()
114
+ project_data = json.loads((tmp_path / "opencode.json").read_text().split("\n", 1)[1])
115
+ assert "mcp" in project_data
116
+
117
+
118
+ def test_setup_opencode_global_only(runner, tmp_path, monkeypatch):
119
+ monkeypatch.setattr(
120
+ "evo_cli.commands.opencode.get_global_config_path",
121
+ lambda: tmp_path / "global.jsonc",
122
+ )
123
+ monkeypatch.setattr(
124
+ "evo_cli.commands.opencode.ensure_node_installed",
125
+ lambda: ("node", "npm", "npx"),
126
+ )
127
+ monkeypatch.setattr("evo_cli.commands.opencode.install_mcp_servers", lambda: None)
128
+ monkeypatch.setattr(
129
+ "evo_cli.commands.opencode.install_playwright_browsers", lambda: None
130
+ )
131
+ monkeypatch.setattr("evo_cli.commands.opencode.verify_mcp_servers", lambda: None)
132
+
133
+ project = tmp_path / "project"
134
+ project.mkdir()
135
+ result = runner.invoke(cli, ["setup", "opencode", "--global-only", "--project", str(project)])
136
+ assert result.exit_code == 0
137
+ assert not (project / "opencode.json").exists()
@@ -1 +0,0 @@
1
- 0.7.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