evo-cli 0.8.0__tar.gz → 0.9.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 (44) hide show
  1. {evo_cli-0.8.0 → evo_cli-0.9.0}/PKG-INFO +1 -1
  2. evo_cli-0.9.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/cli.py +2 -0
  4. evo_cli-0.9.0/evo_cli/commands/mcp.py +234 -0
  5. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/opencode.py +21 -5
  6. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/console.py +23 -9
  7. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/PKG-INFO +1 -1
  8. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/SOURCES.txt +3 -0
  9. evo_cli-0.9.0/tests/test_console.py +32 -0
  10. evo_cli-0.9.0/tests/test_mcp.py +88 -0
  11. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_opencode.py +20 -0
  12. evo_cli-0.8.0/evo_cli/VERSION +0 -1
  13. {evo_cli-0.8.0 → evo_cli-0.9.0}/Containerfile +0 -0
  14. {evo_cli-0.8.0 → evo_cli-0.9.0}/HISTORY.md +0 -0
  15. {evo_cli-0.8.0 → evo_cli-0.9.0}/LICENSE +0 -0
  16. {evo_cli-0.8.0 → evo_cli-0.9.0}/MANIFEST.in +0 -0
  17. {evo_cli-0.8.0 → evo_cli-0.9.0}/README.md +0 -0
  18. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/__init__.py +0 -0
  19. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/__main__.py +0 -0
  20. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/base.py +0 -0
  21. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/__init__.py +0 -0
  22. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/cloudflare.py +0 -0
  23. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/localproxy.py +0 -0
  26. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/miniconda.py +0 -0
  27. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/netcheck.py +0 -0
  28. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/plantuml.py +0 -0
  29. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/site2s.py +0 -0
  30. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/ssh.py +0 -0
  31. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/sysmon.py +0 -0
  32. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/wifi.py +0 -0
  33. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  34. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/entry_points.txt +0 -0
  35. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/requires.txt +0 -0
  36. {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/top_level.txt +0 -0
  37. {evo_cli-0.8.0 → evo_cli-0.9.0}/pyproject.toml +0 -0
  38. {evo_cli-0.8.0 → evo_cli-0.9.0}/setup.cfg +0 -0
  39. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/__init__.py +0 -0
  40. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_cli.py +0 -0
  41. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_fix_claude.py +0 -0
  42. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_plantuml.py +0 -0
  43. {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_sysmon.py +0 -0
  44. {evo_cli-0.8.0 → evo_cli-0.9.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.8.0
3
+ Version: 0.9.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
@@ -0,0 +1 @@
1
+ 0.9.0
@@ -5,6 +5,7 @@ from evo_cli.commands.cloudflare import cfssh
5
5
  from evo_cli.commands.fix_claude import f_claude
6
6
  from evo_cli.commands.gdrive import gdrive
7
7
  from evo_cli.commands.localproxy import localproxy
8
+ from evo_cli.commands.mcp import mcp_group
8
9
  from evo_cli.commands.miniconda import miniconda
9
10
  from evo_cli.commands.netcheck import netcheck
10
11
  from evo_cli.commands.opencode import setup_opencode
@@ -50,6 +51,7 @@ cli.add_command(f_claude)
50
51
  cli.add_command(gdrive)
51
52
  cli.add_command(site2s)
52
53
  cli.add_command(localproxy)
54
+ cli.add_command(mcp_group)
53
55
  cli.add_command(netcheck)
54
56
  cli.add_command(plantuml)
55
57
  cli.add_command(sysmon)
@@ -0,0 +1,234 @@
1
+ """Set up MCP servers for Claude Code and OpenCode in one shot."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from evo_cli.commands.opencode import (
13
+ get_global_config_path,
14
+ load_jsonc,
15
+ save_jsonc,
16
+ )
17
+ from evo_cli.console import console, error, info, run_command, step, success, warning
18
+
19
+ EPILOG = Text.from_markup(
20
+ "[bold]Examples[/bold]\n\n"
21
+ " [cyan]evo mcp list[/cyan]\n"
22
+ " [cyan]evo mcp add notion[/cyan]\n"
23
+ " [cyan]evo mcp add notion --claude-only[/cyan]\n"
24
+ " [cyan]evo mcp add notion --opencode-only --project .[/cyan]\n"
25
+ " [cyan]evo mcp add my-server --url https://example.com/mcp --transport http[/cyan]"
26
+ )
27
+
28
+ MCP_REGISTRY = {
29
+ "notion": {
30
+ "transport": "http",
31
+ "url": "https://mcp.notion.com/mcp",
32
+ "description": "Notion workspace - pages, databases, search. OAuth on first use.",
33
+ },
34
+ "context7": {
35
+ "transport": "http",
36
+ "url": "https://mcp.context7.com/mcp",
37
+ "description": "Context7 - up-to-date library docs and code examples.",
38
+ },
39
+ "deepwiki": {
40
+ "transport": "sse",
41
+ "url": "https://mcp.deepwiki.com/sse",
42
+ "description": "DeepWiki - ask questions about any public GitHub repo.",
43
+ },
44
+ }
45
+
46
+
47
+ def to_opencode_config(spec):
48
+ if spec["transport"] in ("http", "sse"):
49
+ config = {"type": "remote", "url": spec["url"], "enabled": True}
50
+ if spec.get("headers"):
51
+ config["headers"] = dict(spec["headers"])
52
+ return config
53
+ config = {"type": "local", "command": list(spec["command"]), "enabled": True}
54
+ if spec.get("env"):
55
+ config["environment"] = dict(spec["env"])
56
+ return config
57
+
58
+
59
+ def claude_has_server(name):
60
+ result = subprocess.run(
61
+ ["claude", "mcp", "get", name],
62
+ capture_output=True,
63
+ text=True,
64
+ )
65
+ return result.returncode == 0
66
+
67
+
68
+ def add_to_claude(name, spec, scope):
69
+ step("Adding to Claude Code")
70
+ if not shutil.which("claude"):
71
+ warning("`claude` CLI not found on PATH; skipping Claude Code")
72
+ info("Install Claude Code, then re-run to register this MCP server")
73
+ return False
74
+
75
+ if claude_has_server(name):
76
+ info(f"MCP server [accent]{name}[/accent] already in Claude Code; skipping")
77
+ return True
78
+
79
+ cmd = ["claude", "mcp", "add", "--scope", scope]
80
+ if spec["transport"] in ("http", "sse"):
81
+ cmd += ["--transport", spec["transport"], name, spec["url"]]
82
+ else:
83
+ for key, value in (spec.get("env") or {}).items():
84
+ cmd += ["-e", f"{key}={value}"]
85
+ cmd += [name, "--", *spec["command"]]
86
+ if spec.get("headers"):
87
+ for key, value in spec["headers"].items():
88
+ cmd += ["--header", f"{key}: {value}"]
89
+
90
+ try:
91
+ run_command(cmd)
92
+ except Exception as exc:
93
+ error(f"Could not add to Claude Code: {exc}")
94
+ return False
95
+ success(f"Added [accent]{name}[/accent] to Claude Code ({scope} scope)")
96
+ info("Run /mcp inside Claude Code to authenticate if the server needs OAuth")
97
+ return True
98
+
99
+
100
+ def add_to_opencode(name, spec, project):
101
+ step("Adding to OpenCode")
102
+ server_config = to_opencode_config(spec)
103
+
104
+ config_path = get_global_config_path()
105
+ existing = load_jsonc(config_path) if config_path.exists() else {}
106
+ mcp = existing.setdefault("mcp", {})
107
+ if name in mcp:
108
+ info(f"MCP server [accent]{name}[/accent] already in OpenCode global; skipping")
109
+ else:
110
+ mcp[name] = server_config
111
+ header = "// OpenCode global configuration generated by evo"
112
+ save_jsonc(config_path, existing, header=header)
113
+ success(f"Added [accent]{name}[/accent] to OpenCode global config")
114
+ info(f"Config: [accent]{config_path}[/accent]")
115
+
116
+ if project:
117
+ target = Path(project).resolve()
118
+ proj_path = target / "opencode.json"
119
+ proj_existing = load_jsonc(proj_path) if proj_path.exists() else {}
120
+ proj_mcp = proj_existing.setdefault("mcp", {})
121
+ if name in proj_mcp:
122
+ info(f"MCP server [accent]{name}[/accent] already in project config; skipping")
123
+ else:
124
+ proj_mcp[name] = server_config
125
+ header = "// OpenCode project configuration generated by evo"
126
+ save_jsonc(proj_path, proj_existing, header=header)
127
+ success(f"Added [accent]{name}[/accent] to project config [accent]{proj_path}[/accent]")
128
+ return True
129
+
130
+
131
+ def resolve_spec(name, url, transport):
132
+ if url:
133
+ if transport not in ("http", "sse"):
134
+ raise click.BadParameter("--transport must be http or sse when using --url")
135
+ return {"transport": transport, "url": url, "description": "custom"}
136
+ if name in MCP_REGISTRY:
137
+ return MCP_REGISTRY[name]
138
+ raise click.BadParameter(
139
+ f"Unknown MCP server '{name}'. Run `evo mcp list` to see known servers, or pass --url to add a custom one."
140
+ )
141
+
142
+
143
+ @click.group("mcp", help="Set up MCP servers for Claude Code and OpenCode.")
144
+ def mcp_group():
145
+ """Register MCP servers across your AI coding tools at once."""
146
+
147
+
148
+ @mcp_group.command("list")
149
+ def list_servers():
150
+ """List the MCP servers bundled in the registry."""
151
+ table = Table(title="Known MCP servers", title_style="step", expand=False)
152
+ table.add_column("Name", style="accent")
153
+ table.add_column("Transport", style="info")
154
+ table.add_column("Endpoint", style="dim")
155
+ table.add_column("Description")
156
+ for name, spec in MCP_REGISTRY.items():
157
+ endpoint = spec.get("url") or " ".join(spec.get("command", []))
158
+ table.add_row(name, spec["transport"], endpoint, spec["description"])
159
+ console.print(table)
160
+ console.print(
161
+ "\nAdd one with [accent]evo mcp add <name>[/accent], "
162
+ "or a custom server with [accent]evo mcp add <name> --url <url>[/accent]."
163
+ )
164
+
165
+
166
+ @mcp_group.command("add", epilog=EPILOG)
167
+ @click.argument("name")
168
+ @click.option("--url", help="Custom remote MCP endpoint (for servers not in the registry).")
169
+ @click.option(
170
+ "--transport",
171
+ type=click.Choice(["http", "sse"]),
172
+ default="http",
173
+ show_default=True,
174
+ help="Transport for a custom --url server.",
175
+ )
176
+ @click.option(
177
+ "--scope",
178
+ type=click.Choice(["local", "user", "project"]),
179
+ default="user",
180
+ show_default=True,
181
+ help="Claude Code config scope.",
182
+ )
183
+ @click.option("--claude-only", is_flag=True, help="Only configure Claude Code.")
184
+ @click.option("--opencode-only", is_flag=True, help="Only configure OpenCode.")
185
+ @click.option(
186
+ "--project",
187
+ type=click.Path(file_okay=False, dir_okay=True),
188
+ help="Also write into a project-level opencode.json at this directory.",
189
+ )
190
+ def add_server(name, url, transport, scope, claude_only, opencode_only, project):
191
+ """Add an MCP server to Claude Code and OpenCode.
192
+
193
+ NAME is a key from the registry (`evo mcp list`) or any label when paired
194
+ with --url for a custom remote server. By default the server is registered
195
+ in both tools; use --claude-only or --opencode-only to limit the scope.
196
+
197
+ Remote servers that use OAuth (like notion) prompt for sign-in on first use:
198
+ run /mcp inside Claude Code, and OpenCode triggers the flow on next launch.
199
+ """
200
+ if claude_only and opencode_only:
201
+ error("--claude-only and --opencode-only are mutually exclusive")
202
+ return
203
+
204
+ try:
205
+ spec = resolve_spec(name, url, transport)
206
+ except click.BadParameter as exc:
207
+ error(str(exc))
208
+ return
209
+
210
+ step(f"evo mcp add {name}")
211
+ info(spec.get("description", "custom"))
212
+
213
+ did_claude = did_opencode = False
214
+ if not opencode_only:
215
+ did_claude = add_to_claude(name, spec, scope)
216
+ if not claude_only:
217
+ did_opencode = add_to_opencode(name, spec, project)
218
+
219
+ console.print()
220
+ targets = []
221
+ if did_claude:
222
+ targets.append("Claude Code")
223
+ if did_opencode:
224
+ targets.append("OpenCode")
225
+ summary = ", ".join(targets) if targets else "no tools (check warnings above)"
226
+ console.print(
227
+ Panel(
228
+ f"MCP server [accent]{name}[/accent] configured for: {summary}.\n\n"
229
+ "Restart OpenCode and authenticate in Claude Code (/mcp) if prompted.",
230
+ title="mcp add complete",
231
+ border_style="success",
232
+ expand=False,
233
+ )
234
+ )
@@ -112,15 +112,29 @@ def ensure_node_installed():
112
112
 
113
113
 
114
114
  def install_mcp_servers():
115
- """Install MCP server npm packages globally via npx."""
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
+ """
116
124
  step("Installing MCP servers")
117
125
  servers = [
118
126
  "@mcp-server/google-search-mcp@latest",
119
127
  "@playwright/mcp@latest",
120
128
  ]
121
129
  for server in servers:
122
- info(f"Installing {server}")
123
- run_command(["npx", "-y", server, "--version"], status=f"Fetching {server}")
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
+ )
124
138
  success("MCP server packages ready")
125
139
 
126
140
 
@@ -131,6 +145,8 @@ def install_playwright_browsers():
131
145
  run_command(
132
146
  ["npx", "playwright", "install", "chromium"],
133
147
  status="Downloading Chromium for Playwright",
148
+ stdin=subprocess.DEVNULL,
149
+ timeout=600,
134
150
  )
135
151
  success("Playwright Chromium installed")
136
152
  except Exception as exc:
@@ -213,7 +229,7 @@ def configure_opencode_global():
213
229
  existing = load_jsonc(config_path) if config_path.exists() else {}
214
230
  merged = merge_mcp_config(existing, DEFAULT_MCP_SERVERS)
215
231
 
216
- header = "// OpenCode global configuration generated by evo setup opencode"
232
+ header = "// OpenCode global configuration generated by evo"
217
233
  save_jsonc(config_path, merged, header=header)
218
234
  success(f"Global config written to [accent]{config_path}[/accent]")
219
235
  return config_path
@@ -228,7 +244,7 @@ def configure_opencode_project(project_path):
228
244
  existing = load_jsonc(config_path) if config_path.exists() else {}
229
245
  merged = merge_mcp_config(existing, DEFAULT_MCP_SERVERS)
230
246
 
231
- header = "// OpenCode project configuration generated by evo setup opencode"
247
+ header = "// OpenCode project configuration generated by evo"
232
248
  save_jsonc(config_path, merged, header=header)
233
249
  success(f"Project config written to [accent]{config_path}[/accent]")
234
250
  return config_path
@@ -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.8.0
3
+ Version: 0.9.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
@@ -21,6 +21,7 @@ evo_cli/commands/cloudflare.py
21
21
  evo_cli/commands/fix_claude.py
22
22
  evo_cli/commands/gdrive.py
23
23
  evo_cli/commands/localproxy.py
24
+ evo_cli/commands/mcp.py
24
25
  evo_cli/commands/miniconda.py
25
26
  evo_cli/commands/netcheck.py
26
27
  evo_cli/commands/opencode.py
@@ -31,7 +32,9 @@ evo_cli/commands/sysmon.py
31
32
  evo_cli/commands/wifi.py
32
33
  tests/__init__.py
33
34
  tests/test_cli.py
35
+ tests/test_console.py
34
36
  tests/test_fix_claude.py
37
+ tests/test_mcp.py
35
38
  tests/test_opencode.py
36
39
  tests/test_plantuml.py
37
40
  tests/test_sysmon.py
@@ -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,88 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+
4
+ from evo_cli.cli import cli
5
+ from evo_cli.commands.mcp import resolve_spec, to_opencode_config
6
+ from evo_cli.commands.opencode import load_jsonc
7
+
8
+
9
+ @pytest.fixture
10
+ def runner():
11
+ return CliRunner()
12
+
13
+
14
+ def test_mcp_command_is_registered():
15
+ assert "mcp" in cli.commands
16
+ assert "add" in cli.commands["mcp"].commands
17
+ assert "list" in cli.commands["mcp"].commands
18
+
19
+
20
+ def test_mcp_list_runs(runner):
21
+ result = runner.invoke(cli, ["mcp", "list"])
22
+ assert result.exit_code == 0
23
+ assert "notion" in result.output
24
+
25
+
26
+ def test_to_opencode_config_remote():
27
+ spec = {"transport": "http", "url": "https://example.com/mcp"}
28
+ assert to_opencode_config(spec) == {
29
+ "type": "remote",
30
+ "url": "https://example.com/mcp",
31
+ "enabled": True,
32
+ }
33
+
34
+
35
+ def test_to_opencode_config_local():
36
+ spec = {"transport": "stdio", "command": ["npx", "-y", "thing"]}
37
+ assert to_opencode_config(spec) == {
38
+ "type": "local",
39
+ "command": ["npx", "-y", "thing"],
40
+ "enabled": True,
41
+ }
42
+
43
+
44
+ def test_resolve_spec_from_registry():
45
+ spec = resolve_spec("notion", None, "http")
46
+ assert spec["url"] == "https://mcp.notion.com/mcp"
47
+
48
+
49
+ def test_resolve_spec_custom_url():
50
+ spec = resolve_spec("anything", "https://x.dev/mcp", "http")
51
+ assert spec["transport"] == "http"
52
+ assert spec["url"] == "https://x.dev/mcp"
53
+
54
+
55
+ def test_resolve_spec_unknown_raises():
56
+ with pytest.raises(Exception):
57
+ resolve_spec("nope", None, "http")
58
+
59
+
60
+ def test_add_opencode_only_writes_configs(runner, tmp_path, monkeypatch):
61
+ global_path = tmp_path / "global.jsonc"
62
+ monkeypatch.setattr(
63
+ "evo_cli.commands.mcp.get_global_config_path",
64
+ lambda: global_path,
65
+ )
66
+ project = tmp_path / "project"
67
+ project.mkdir()
68
+
69
+ result = runner.invoke(
70
+ cli,
71
+ ["mcp", "add", "notion", "--opencode-only", "--project", str(project)],
72
+ )
73
+ assert result.exit_code == 0
74
+ assert load_jsonc(global_path)["mcp"]["notion"]["url"] == "https://mcp.notion.com/mcp"
75
+ assert load_jsonc(project / "opencode.json")["mcp"]["notion"]["type"] == "remote"
76
+
77
+
78
+ def test_add_rejects_both_only_flags(runner, tmp_path, monkeypatch):
79
+ monkeypatch.setattr(
80
+ "evo_cli.commands.mcp.get_global_config_path",
81
+ lambda: tmp_path / "global.jsonc",
82
+ )
83
+ result = runner.invoke(
84
+ cli,
85
+ ["mcp", "add", "notion", "--claude-only", "--opencode-only"],
86
+ )
87
+ assert result.exit_code == 0
88
+ assert "mutually exclusive" in result.output
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import subprocess
2
3
 
3
4
  import pytest
4
5
  from click.testing import CliRunner
@@ -8,6 +9,7 @@ from evo_cli.commands.opencode import (
8
9
  DEFAULT_MCP_SERVERS,
9
10
  configure_opencode_global,
10
11
  configure_opencode_project,
12
+ install_mcp_servers,
11
13
  load_jsonc,
12
14
  merge_mcp_config,
13
15
  save_jsonc,
@@ -73,6 +75,24 @@ def test_configure_opencode_project(tmp_path):
73
75
  assert set(data["mcp"].keys()) == {"google-search", "playwright"}
74
76
 
75
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
+
76
96
  def test_setup_opencode_writes_global_and_project(runner, tmp_path, monkeypatch):
77
97
  monkeypatch.setattr(
78
98
  "evo_cli.commands.opencode.get_global_config_path",
@@ -1 +0,0 @@
1
- 0.8.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