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.
- {evo_cli-0.8.0 → evo_cli-0.9.0}/PKG-INFO +1 -1
- evo_cli-0.9.0/evo_cli/VERSION +1 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/cli.py +2 -0
- evo_cli-0.9.0/evo_cli/commands/mcp.py +234 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/opencode.py +21 -5
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/console.py +23 -9
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/SOURCES.txt +3 -0
- evo_cli-0.9.0/tests/test_console.py +32 -0
- evo_cli-0.9.0/tests/test_mcp.py +88 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_opencode.py +20 -0
- evo_cli-0.8.0/evo_cli/VERSION +0 -1
- {evo_cli-0.8.0 → evo_cli-0.9.0}/Containerfile +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/HISTORY.md +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/LICENSE +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/MANIFEST.in +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/README.md +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/base.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/pyproject.toml +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/setup.cfg +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/__init__.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_cli.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.8.0 → evo_cli-0.9.0}/tests/test_wifi.py +0 -0
|
@@ -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
|
-
"""
|
|
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"
|
|
123
|
-
run_command(
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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()
|
|
@@ -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",
|
evo_cli-0.8.0/evo_cli/VERSION
DELETED
|
@@ -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
|
|
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
|