evo-cli 0.9.1__tar.gz → 0.10.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.
- {evo_cli-0.9.1 → evo_cli-0.10.1}/PKG-INFO +1 -1
- evo_cli-0.10.1/evo_cli/VERSION +1 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/mcp.py +55 -49
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/opencode.py +79 -36
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/console.py +13 -2
- evo_cli-0.10.1/evo_cli/mcp_registry.py +101 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/SOURCES.txt +1 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_mcp.py +42 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_opencode.py +57 -6
- evo_cli-0.9.1/evo_cli/VERSION +0 -1
- {evo_cli-0.9.1 → evo_cli-0.10.1}/Containerfile +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/HISTORY.md +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/LICENSE +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/MANIFEST.in +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/README.md +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/base.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/cli.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/pyproject.toml +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/setup.cfg +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/__init__.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_cli.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_console.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.9.1 → evo_cli-0.10.1}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.10.1
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Set up MCP servers for Claude Code and OpenCode in one shot."""
|
|
2
2
|
|
|
3
|
+
import shlex
|
|
3
4
|
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
from pathlib import Path
|
|
@@ -15,46 +16,18 @@ from evo_cli.commands.opencode import (
|
|
|
15
16
|
save_jsonc,
|
|
16
17
|
)
|
|
17
18
|
from evo_cli.console import console, error, info, run_command, step, success, warning
|
|
19
|
+
from evo_cli.mcp_registry import MCP_REGISTRY, is_remote, to_opencode_config
|
|
18
20
|
|
|
19
21
|
EPILOG = Text.from_markup(
|
|
20
22
|
"[bold]Examples[/bold]\n\n"
|
|
21
23
|
" [cyan]evo mcp list[/cyan]\n"
|
|
22
|
-
" [cyan]evo mcp add
|
|
24
|
+
" [cyan]evo mcp add playwright[/cyan]\n"
|
|
23
25
|
" [cyan]evo mcp add notion --claude-only[/cyan]\n"
|
|
24
26
|
" [cyan]evo mcp add notion --opencode-only --project .[/cyan]\n"
|
|
25
|
-
" [cyan]evo mcp add my-
|
|
27
|
+
" [cyan]evo mcp add my-remote --url https://example.com/mcp --transport http[/cyan]\n"
|
|
28
|
+
' [cyan]evo mcp add my-local --command "npx -y some-mcp" --env API_KEY=xxx[/cyan]'
|
|
26
29
|
)
|
|
27
30
|
|
|
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
31
|
|
|
59
32
|
def claude_has_server(name):
|
|
60
33
|
result = subprocess.run(
|
|
@@ -77,7 +50,7 @@ def add_to_claude(name, spec, scope):
|
|
|
77
50
|
return True
|
|
78
51
|
|
|
79
52
|
cmd = ["claude", "mcp", "add", "--scope", scope]
|
|
80
|
-
if spec
|
|
53
|
+
if is_remote(spec):
|
|
81
54
|
cmd += ["--transport", spec["transport"], name, spec["url"]]
|
|
82
55
|
else:
|
|
83
56
|
for key, value in (spec.get("env") or {}).items():
|
|
@@ -128,15 +101,26 @@ def add_to_opencode(name, spec, project):
|
|
|
128
101
|
return True
|
|
129
102
|
|
|
130
103
|
|
|
131
|
-
def resolve_spec(name, url, transport):
|
|
104
|
+
def resolve_spec(name, url, transport, command=None, env=None):
|
|
105
|
+
if url and command:
|
|
106
|
+
raise click.BadParameter("Use either --url (remote) or --command (local), not both.")
|
|
132
107
|
if url:
|
|
133
108
|
if transport not in ("http", "sse"):
|
|
134
109
|
raise click.BadParameter("--transport must be http or sse when using --url")
|
|
135
|
-
return {"transport": transport, "url": url, "description": "custom"}
|
|
110
|
+
return {"transport": transport, "url": url, "description": "custom remote server"}
|
|
111
|
+
if command:
|
|
112
|
+
argv = shlex.split(command) if isinstance(command, str) else list(command)
|
|
113
|
+
if not argv:
|
|
114
|
+
raise click.BadParameter("--command must contain a command to run.")
|
|
115
|
+
spec = {"transport": "stdio", "command": argv, "description": "custom local server"}
|
|
116
|
+
if env:
|
|
117
|
+
spec["env"] = dict(env)
|
|
118
|
+
return spec
|
|
136
119
|
if name in MCP_REGISTRY:
|
|
137
120
|
return MCP_REGISTRY[name]
|
|
138
121
|
raise click.BadParameter(
|
|
139
|
-
f"Unknown MCP server '{name}'. Run `evo mcp list` to see
|
|
122
|
+
f"Unknown MCP server '{name}'. Run `evo mcp list` to see the catalog, "
|
|
123
|
+
"or pass --url (remote) / --command (local) to add a custom one."
|
|
140
124
|
)
|
|
141
125
|
|
|
142
126
|
|
|
@@ -147,25 +131,38 @@ def mcp_group():
|
|
|
147
131
|
|
|
148
132
|
@mcp_group.command("list")
|
|
149
133
|
def list_servers():
|
|
150
|
-
"""List the MCP servers
|
|
151
|
-
table = Table(title="
|
|
134
|
+
"""List the MCP servers in the curated library."""
|
|
135
|
+
table = Table(title="MCP server library", title_style="step", expand=False)
|
|
152
136
|
table.add_column("Name", style="accent")
|
|
153
|
-
table.add_column("
|
|
154
|
-
table.add_column("
|
|
137
|
+
table.add_column("Category", style="info")
|
|
138
|
+
table.add_column("Type", style="info")
|
|
139
|
+
table.add_column("Endpoint / command", style="dim", overflow="fold")
|
|
155
140
|
table.add_column("Description")
|
|
156
141
|
for name, spec in MCP_REGISTRY.items():
|
|
157
142
|
endpoint = spec.get("url") or " ".join(spec.get("command", []))
|
|
158
|
-
|
|
143
|
+
kind = "remote" if is_remote(spec) else "local"
|
|
144
|
+
table.add_row(name, spec.get("category", "-"), kind, endpoint, spec["description"])
|
|
159
145
|
console.print(table)
|
|
160
146
|
console.print(
|
|
161
|
-
"\nAdd one with [accent]evo mcp add <name>[/accent], "
|
|
162
|
-
|
|
147
|
+
"\nAdd one with [accent]evo mcp add <name>[/accent], a custom remote with "
|
|
148
|
+
'[accent]--url <url>[/accent], or a custom local with [accent]--command "<cmd>"[/accent].'
|
|
163
149
|
)
|
|
164
150
|
|
|
165
151
|
|
|
166
152
|
@mcp_group.command("add", epilog=EPILOG)
|
|
167
153
|
@click.argument("name")
|
|
168
|
-
@click.option("--url", help="Custom remote MCP endpoint (for servers not in the
|
|
154
|
+
@click.option("--url", help="Custom remote MCP endpoint (for servers not in the library).")
|
|
155
|
+
@click.option(
|
|
156
|
+
"--command",
|
|
157
|
+
help='Custom local (stdio) MCP server command, e.g. "npx -y some-mcp".',
|
|
158
|
+
)
|
|
159
|
+
@click.option(
|
|
160
|
+
"--env",
|
|
161
|
+
"env",
|
|
162
|
+
multiple=True,
|
|
163
|
+
metavar="KEY=VALUE",
|
|
164
|
+
help="Environment variable for a custom --command server (repeatable).",
|
|
165
|
+
)
|
|
169
166
|
@click.option(
|
|
170
167
|
"--transport",
|
|
171
168
|
type=click.Choice(["http", "sse"]),
|
|
@@ -187,12 +184,13 @@ def list_servers():
|
|
|
187
184
|
type=click.Path(file_okay=False, dir_okay=True),
|
|
188
185
|
help="Also write into a project-level opencode.json at this directory.",
|
|
189
186
|
)
|
|
190
|
-
def add_server(name, url, transport, scope, claude_only, opencode_only, project):
|
|
187
|
+
def add_server(name, url, command, env, transport, scope, claude_only, opencode_only, project):
|
|
191
188
|
"""Add an MCP server to Claude Code and OpenCode.
|
|
192
189
|
|
|
193
|
-
NAME is a key from the
|
|
194
|
-
with --url
|
|
195
|
-
in both tools; use --claude-only or
|
|
190
|
+
NAME is a key from the library (`evo mcp list`), or any label when paired
|
|
191
|
+
with --url (custom remote server) or --command (custom local server). By
|
|
192
|
+
default the server is registered in both tools; use --claude-only or
|
|
193
|
+
--opencode-only to limit the scope.
|
|
196
194
|
|
|
197
195
|
Remote servers that use OAuth (like notion) prompt for sign-in on first use:
|
|
198
196
|
run /mcp inside Claude Code, and OpenCode triggers the flow on next launch.
|
|
@@ -201,8 +199,16 @@ def add_server(name, url, transport, scope, claude_only, opencode_only, project)
|
|
|
201
199
|
error("--claude-only and --opencode-only are mutually exclusive")
|
|
202
200
|
return
|
|
203
201
|
|
|
202
|
+
env_map = {}
|
|
203
|
+
for item in env:
|
|
204
|
+
if "=" not in item:
|
|
205
|
+
error(f"Invalid --env '{item}'; expected KEY=VALUE")
|
|
206
|
+
return
|
|
207
|
+
key, value = item.split("=", 1)
|
|
208
|
+
env_map[key] = value
|
|
209
|
+
|
|
204
210
|
try:
|
|
205
|
-
spec = resolve_spec(name, url, transport)
|
|
211
|
+
spec = resolve_spec(name, url, transport, command, env_map or None)
|
|
206
212
|
except click.BadParameter as exc:
|
|
207
213
|
error(str(exc))
|
|
208
214
|
return
|
|
@@ -10,7 +10,8 @@ import rich_click as click
|
|
|
10
10
|
from rich.panel import Panel
|
|
11
11
|
from rich.text import Text
|
|
12
12
|
|
|
13
|
-
from evo_cli.console import console, error, info, run_command, step, success, warning
|
|
13
|
+
from evo_cli.console import console, error, info, resolve_executable, run_command, step, success, warning
|
|
14
|
+
from evo_cli.mcp_registry import opencode_servers
|
|
14
15
|
|
|
15
16
|
EPILOG = Text.from_markup(
|
|
16
17
|
"[bold]Examples[/bold]\n\n"
|
|
@@ -19,18 +20,14 @@ EPILOG = Text.from_markup(
|
|
|
19
20
|
" [cyan]evo setup opencode --project .[/cyan]"
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
"command": ["npx", "-y", "@playwright/mcp@latest"],
|
|
31
|
-
"enabled": True,
|
|
32
|
-
},
|
|
33
|
-
}
|
|
23
|
+
# Servers bootstrapped by `evo setup opencode`, pulled from the shared library
|
|
24
|
+
# (evo_cli/mcp_registry.py). Add more servers anytime with `evo mcp add <name>`.
|
|
25
|
+
DEFAULT_MCP_SERVERS = opencode_servers("google-search", "playwright")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _local_mcp_commands():
|
|
29
|
+
"""Commands for the local (stdio) servers in DEFAULT_MCP_SERVERS."""
|
|
30
|
+
return [(name, list(cfg["command"])) for name, cfg in DEFAULT_MCP_SERVERS.items() if cfg.get("type") == "local"]
|
|
34
31
|
|
|
35
32
|
|
|
36
33
|
def is_windows():
|
|
@@ -55,7 +52,7 @@ def ensure_node_installed():
|
|
|
55
52
|
npx_cmd = shutil.which("npx")
|
|
56
53
|
|
|
57
54
|
if node_cmd and npm_cmd and npx_cmd:
|
|
58
|
-
result = subprocess.run(["node", "--version"], capture_output=True, text=True, check=True)
|
|
55
|
+
result = subprocess.run(resolve_executable(["node", "--version"]), capture_output=True, text=True, check=True)
|
|
59
56
|
info(f"Node.js found: {result.stdout.strip()}")
|
|
60
57
|
return node_cmd, npm_cmd, npx_cmd
|
|
61
58
|
|
|
@@ -122,15 +119,11 @@ def install_mcp_servers():
|
|
|
122
119
|
the whole setup; the verify step confirms the servers actually work.
|
|
123
120
|
"""
|
|
124
121
|
step("Installing MCP servers")
|
|
125
|
-
|
|
126
|
-
"
|
|
127
|
-
"@playwright/mcp@latest",
|
|
128
|
-
]
|
|
129
|
-
for server in servers:
|
|
130
|
-
info(f"Fetching {server}")
|
|
122
|
+
for name, command in _local_mcp_commands():
|
|
123
|
+
info(f"Fetching {name}")
|
|
131
124
|
run_command(
|
|
132
|
-
[
|
|
133
|
-
status=f"Fetching {
|
|
125
|
+
[*command, "--version"],
|
|
126
|
+
status=f"Fetching {name}",
|
|
134
127
|
stdin=subprocess.DEVNULL,
|
|
135
128
|
timeout=180,
|
|
136
129
|
check=False,
|
|
@@ -154,6 +147,47 @@ def install_playwright_browsers():
|
|
|
154
147
|
info("You can retry later with: npx playwright install chromium")
|
|
155
148
|
|
|
156
149
|
|
|
150
|
+
def opencode_version():
|
|
151
|
+
"""Return the installed OpenCode version string, or None if not on PATH."""
|
|
152
|
+
if not shutil.which("opencode"):
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
resolve_executable(["opencode", "--version"]), capture_output=True, text=True, timeout=30
|
|
157
|
+
)
|
|
158
|
+
return result.stdout.strip() or "unknown"
|
|
159
|
+
except Exception:
|
|
160
|
+
return "unknown"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def install_opencode(npm_cmd="npm"):
|
|
164
|
+
"""Install the OpenCode CLI globally via npm if it is not already present."""
|
|
165
|
+
step("Installing OpenCode")
|
|
166
|
+
version = opencode_version()
|
|
167
|
+
if version is not None:
|
|
168
|
+
info(f"OpenCode already installed ({version}); skipping")
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
run_command(
|
|
173
|
+
[npm_cmd, "install", "-g", "opencode-ai@latest"],
|
|
174
|
+
status="Installing OpenCode via npm",
|
|
175
|
+
timeout=300,
|
|
176
|
+
)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
warning(f"Could not install OpenCode automatically: {exc}")
|
|
179
|
+
info("Install manually: npm i -g opencode-ai@latest (or curl -fsSL https://opencode.ai/install | bash)")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
version = opencode_version()
|
|
183
|
+
if version is None:
|
|
184
|
+
warning("OpenCode installed but `opencode` is not on PATH yet")
|
|
185
|
+
info("Open a new shell, or add your npm global bin directory to PATH")
|
|
186
|
+
return False
|
|
187
|
+
success(f"OpenCode installed ({version})")
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
157
191
|
def load_jsonc(path):
|
|
158
192
|
"""Load a JSONC file, stripping // comments.
|
|
159
193
|
|
|
@@ -258,14 +292,10 @@ def verify_mcp_servers():
|
|
|
258
292
|
'"params":{"protocolVersion":"2024-11-05","capabilities":{},'
|
|
259
293
|
'"clientInfo":{"name":"evo-cli","version":"1.0"}}}'
|
|
260
294
|
)
|
|
261
|
-
|
|
262
|
-
("playwright", ["npx", "-y", "@playwright/mcp@latest"]),
|
|
263
|
-
("google-search", ["npx", "-y", "@mcp-server/google-search-mcp@latest"]),
|
|
264
|
-
]
|
|
265
|
-
for name, cmd in checks:
|
|
295
|
+
for name, cmd in _local_mcp_commands():
|
|
266
296
|
try:
|
|
267
297
|
result = subprocess.run(
|
|
268
|
-
cmd,
|
|
298
|
+
resolve_executable(cmd),
|
|
269
299
|
input=init_message,
|
|
270
300
|
capture_output=True,
|
|
271
301
|
text=True,
|
|
@@ -279,15 +309,17 @@ def verify_mcp_servers():
|
|
|
279
309
|
warning(f"Could not verify MCP server [accent]{name}[/accent]: {exc}")
|
|
280
310
|
|
|
281
311
|
|
|
282
|
-
def run_setup_opencode(global_only, project):
|
|
312
|
+
def run_setup_opencode(global_only, project, skip_install=False):
|
|
283
313
|
step("evo setup opencode")
|
|
284
314
|
|
|
285
315
|
try:
|
|
286
|
-
ensure_node_installed()
|
|
316
|
+
_, npm_cmd, _ = ensure_node_installed()
|
|
287
317
|
except Exception as exc:
|
|
288
318
|
error(str(exc))
|
|
289
319
|
return
|
|
290
320
|
|
|
321
|
+
if not skip_install:
|
|
322
|
+
install_opencode(npm_cmd)
|
|
291
323
|
install_mcp_servers()
|
|
292
324
|
install_playwright_browsers()
|
|
293
325
|
|
|
@@ -304,10 +336,15 @@ def run_setup_opencode(global_only, project):
|
|
|
304
336
|
if project_path:
|
|
305
337
|
paths_text += f"\nProject: [accent]{project_path}[/accent]"
|
|
306
338
|
|
|
339
|
+
opencode_note = (
|
|
340
|
+
"Run `opencode` to start."
|
|
341
|
+
if opencode_version() is not None
|
|
342
|
+
else "Install OpenCode with: npm i -g opencode-ai@latest"
|
|
343
|
+
)
|
|
307
344
|
console.print(
|
|
308
345
|
Panel(
|
|
309
346
|
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.",
|
|
347
|
+
f"{opencode_note} Restart OpenCode to load the new MCP tools.",
|
|
311
348
|
title="setup opencode complete",
|
|
312
349
|
border_style="success",
|
|
313
350
|
expand=False,
|
|
@@ -321,19 +358,25 @@ def run_setup_opencode(global_only, project):
|
|
|
321
358
|
is_flag=True,
|
|
322
359
|
help="Only update the global OpenCode config; skip project-level config.",
|
|
323
360
|
)
|
|
361
|
+
@click.option(
|
|
362
|
+
"--skip-install",
|
|
363
|
+
is_flag=True,
|
|
364
|
+
help="Skip installing the OpenCode CLI; only set up MCP servers and config.",
|
|
365
|
+
)
|
|
324
366
|
@click.option(
|
|
325
367
|
"--project",
|
|
326
368
|
type=click.Path(file_okay=False, dir_okay=True, writable=True),
|
|
327
369
|
help="Project directory to write opencode.json into. Defaults to the current directory.",
|
|
328
370
|
)
|
|
329
|
-
def setup_opencode(global_only, project):
|
|
330
|
-
"""Install Node.js (if needed),
|
|
371
|
+
def setup_opencode(global_only, skip_install, project):
|
|
372
|
+
"""Install OpenCode and Node.js (if needed), its MCP servers, and write config files.
|
|
331
373
|
|
|
332
374
|
This command bootstraps a fresh machine with the same OpenCode + MCP setup
|
|
333
|
-
used across devices. It installs the
|
|
334
|
-
|
|
375
|
+
used across devices. It installs the OpenCode CLI (via npm, unless
|
|
376
|
+
--skip-install) and the google-search and playwright MCP servers, downloads
|
|
377
|
+
the Playwright Chromium browser, and writes both global
|
|
335
378
|
(~/.config/opencode/opencode.jsonc) and project-level (opencode.json) configs.
|
|
336
379
|
|
|
337
380
|
No credentials are bundled; only public MCP server references are added.
|
|
338
381
|
"""
|
|
339
|
-
run_setup_opencode(global_only, project)
|
|
382
|
+
run_setup_opencode(global_only, project, skip_install=skip_install)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import shutil
|
|
1
2
|
import subprocess
|
|
2
3
|
import urllib.request
|
|
3
4
|
|
|
@@ -52,9 +53,19 @@ def step(title):
|
|
|
52
53
|
console.rule(f"[step]{title}[/step]", style="step", align="left")
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
def resolve_executable(cmd):
|
|
57
|
+
cmd = [str(part) for part in cmd]
|
|
58
|
+
if cmd:
|
|
59
|
+
resolved = shutil.which(cmd[0])
|
|
60
|
+
if resolved:
|
|
61
|
+
cmd[0] = resolved
|
|
62
|
+
return cmd
|
|
63
|
+
|
|
64
|
+
|
|
55
65
|
def run_command(cmd, capture=False, check=True, input_text=None, status=None, timeout=None, stdin=None):
|
|
56
66
|
cmd = [str(part) for part in cmd]
|
|
57
67
|
console.print(f"[cmd]$ {' '.join(cmd)}[/cmd]")
|
|
68
|
+
exec_cmd = resolve_executable(cmd)
|
|
58
69
|
|
|
59
70
|
run_kwargs = {"text": True, "input": input_text, "timeout": timeout}
|
|
60
71
|
# subprocess.run rejects passing both `input` and `stdin`. Only attach an
|
|
@@ -66,12 +77,12 @@ def run_command(cmd, capture=False, check=True, input_text=None, status=None, ti
|
|
|
66
77
|
try:
|
|
67
78
|
if status:
|
|
68
79
|
with console.status(f"[info]{status}[/info]", spinner="dots"):
|
|
69
|
-
result = subprocess.run(
|
|
80
|
+
result = subprocess.run(exec_cmd, capture_output=True, **run_kwargs)
|
|
70
81
|
output = (result.stdout or "").strip()
|
|
71
82
|
if output:
|
|
72
83
|
console.print(output)
|
|
73
84
|
else:
|
|
74
|
-
result = subprocess.run(
|
|
85
|
+
result = subprocess.run(exec_cmd, capture_output=capture, **run_kwargs)
|
|
75
86
|
except subprocess.TimeoutExpired as exc:
|
|
76
87
|
console.print(f"[error]command timed out after {timeout}s: {' '.join(cmd)}[/error]")
|
|
77
88
|
if check:
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Curated library of common MCP servers, shared across evo commands.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for the MCP servers evo knows about. Both
|
|
4
|
+
``evo mcp add`` / ``evo mcp list`` (commands/mcp.py) and ``evo setup opencode``
|
|
5
|
+
(commands/opencode.py) read from this catalog, so a server is defined once and
|
|
6
|
+
wired everywhere.
|
|
7
|
+
|
|
8
|
+
Each entry is a transport-neutral spec:
|
|
9
|
+
|
|
10
|
+
- remote: {"transport": "http" | "sse", "url": ...}
|
|
11
|
+
- local: {"transport": "stdio", "command": [...], "env": {...}}
|
|
12
|
+
|
|
13
|
+
plus metadata used by ``evo mcp list``: "category", "homepage", "description".
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
MCP_REGISTRY = {
|
|
17
|
+
# --- Local servers (run on demand via npx; nothing to host) ---
|
|
18
|
+
"playwright": {
|
|
19
|
+
"transport": "stdio",
|
|
20
|
+
"command": ["npx", "-y", "@playwright/mcp@latest"],
|
|
21
|
+
"category": "browser",
|
|
22
|
+
"homepage": "https://github.com/microsoft/playwright-mcp",
|
|
23
|
+
"description": "Playwright (Microsoft) - drive a real browser: navigate, click, snapshot.",
|
|
24
|
+
},
|
|
25
|
+
"google-search": {
|
|
26
|
+
"transport": "stdio",
|
|
27
|
+
"command": ["npx", "-y", "@mcp-server/google-search-mcp@latest"],
|
|
28
|
+
"category": "search",
|
|
29
|
+
"homepage": "https://www.npmjs.com/package/@mcp-server/google-search-mcp",
|
|
30
|
+
"description": "Google Search - run web searches and fetch result pages.",
|
|
31
|
+
},
|
|
32
|
+
"sequential-thinking": {
|
|
33
|
+
"transport": "stdio",
|
|
34
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
|
|
35
|
+
"category": "reasoning",
|
|
36
|
+
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
|
37
|
+
"description": "Sequential Thinking - structured step-by-step reasoning scratchpad.",
|
|
38
|
+
},
|
|
39
|
+
"memory": {
|
|
40
|
+
"transport": "stdio",
|
|
41
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-memory"],
|
|
42
|
+
"category": "memory",
|
|
43
|
+
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
|
44
|
+
"description": "Memory - persistent knowledge-graph memory across sessions.",
|
|
45
|
+
},
|
|
46
|
+
# --- Remote servers (hosted; OAuth/token on first use where noted) ---
|
|
47
|
+
"context7": {
|
|
48
|
+
"transport": "http",
|
|
49
|
+
"url": "https://mcp.context7.com/mcp",
|
|
50
|
+
"category": "docs",
|
|
51
|
+
"homepage": "https://github.com/upstash/context7",
|
|
52
|
+
"description": "Context7 - up-to-date library docs and code examples.",
|
|
53
|
+
},
|
|
54
|
+
"deepwiki": {
|
|
55
|
+
"transport": "sse",
|
|
56
|
+
"url": "https://mcp.deepwiki.com/sse",
|
|
57
|
+
"category": "docs",
|
|
58
|
+
"homepage": "https://deepwiki.com",
|
|
59
|
+
"description": "DeepWiki - ask questions about any public GitHub repo.",
|
|
60
|
+
},
|
|
61
|
+
"github": {
|
|
62
|
+
"transport": "http",
|
|
63
|
+
"url": "https://api.githubcopilot.com/mcp/",
|
|
64
|
+
"category": "dev",
|
|
65
|
+
"homepage": "https://github.com/github/github-mcp-server",
|
|
66
|
+
"description": "GitHub - repos, issues, PRs, code search. OAuth/PAT on first use.",
|
|
67
|
+
},
|
|
68
|
+
"notion": {
|
|
69
|
+
"transport": "http",
|
|
70
|
+
"url": "https://mcp.notion.com/mcp",
|
|
71
|
+
"category": "productivity",
|
|
72
|
+
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
|
73
|
+
"description": "Notion workspace - pages, databases, search. OAuth on first use.",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_remote(spec):
|
|
79
|
+
"""Whether a spec describes a hosted (remote) MCP server."""
|
|
80
|
+
return spec["transport"] in ("http", "sse")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def to_opencode_config(spec):
|
|
84
|
+
"""Translate a registry spec into an OpenCode ``mcp`` entry."""
|
|
85
|
+
if is_remote(spec):
|
|
86
|
+
config = {"type": "remote", "url": spec["url"], "enabled": True}
|
|
87
|
+
if spec.get("headers"):
|
|
88
|
+
config["headers"] = dict(spec["headers"])
|
|
89
|
+
return config
|
|
90
|
+
config = {"type": "local", "command": list(spec["command"]), "enabled": True}
|
|
91
|
+
if spec.get("env"):
|
|
92
|
+
config["environment"] = dict(spec["env"])
|
|
93
|
+
return config
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def opencode_servers(*names):
|
|
97
|
+
"""Build an OpenCode ``mcp`` mapping for the named registry servers.
|
|
98
|
+
|
|
99
|
+
Order follows the arguments so callers control how servers appear in config.
|
|
100
|
+
"""
|
|
101
|
+
return {name: to_opencode_config(MCP_REGISTRY[name]) for name in names}
|
|
@@ -46,12 +46,41 @@ def test_resolve_spec_from_registry():
|
|
|
46
46
|
assert spec["url"] == "https://mcp.notion.com/mcp"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def test_resolve_spec_playwright_is_local():
|
|
50
|
+
spec = resolve_spec("playwright", None, "http")
|
|
51
|
+
assert spec["transport"] == "stdio"
|
|
52
|
+
assert spec["command"] == ["npx", "-y", "@playwright/mcp@latest"]
|
|
53
|
+
assert to_opencode_config(spec) == {
|
|
54
|
+
"type": "local",
|
|
55
|
+
"command": ["npx", "-y", "@playwright/mcp@latest"],
|
|
56
|
+
"enabled": True,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_mcp_list_shows_playwright(runner):
|
|
61
|
+
result = runner.invoke(cli, ["mcp", "list"])
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "playwright" in result.output
|
|
64
|
+
|
|
65
|
+
|
|
49
66
|
def test_resolve_spec_custom_url():
|
|
50
67
|
spec = resolve_spec("anything", "https://x.dev/mcp", "http")
|
|
51
68
|
assert spec["transport"] == "http"
|
|
52
69
|
assert spec["url"] == "https://x.dev/mcp"
|
|
53
70
|
|
|
54
71
|
|
|
72
|
+
def test_resolve_spec_custom_command():
|
|
73
|
+
spec = resolve_spec("my-local", None, "http", command="npx -y some-mcp", env={"K": "V"})
|
|
74
|
+
assert spec["transport"] == "stdio"
|
|
75
|
+
assert spec["command"] == ["npx", "-y", "some-mcp"]
|
|
76
|
+
assert spec["env"] == {"K": "V"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_resolve_spec_url_and_command_conflict():
|
|
80
|
+
with pytest.raises(Exception):
|
|
81
|
+
resolve_spec("x", "https://x.dev/mcp", "http", command="npx -y some-mcp")
|
|
82
|
+
|
|
83
|
+
|
|
55
84
|
def test_resolve_spec_unknown_raises():
|
|
56
85
|
with pytest.raises(Exception):
|
|
57
86
|
resolve_spec("nope", None, "http")
|
|
@@ -75,6 +104,19 @@ def test_add_opencode_only_writes_configs(runner, tmp_path, monkeypatch):
|
|
|
75
104
|
assert load_jsonc(project / "opencode.json")["mcp"]["notion"]["type"] == "remote"
|
|
76
105
|
|
|
77
106
|
|
|
107
|
+
def test_add_playwright_opencode_only_writes_local(runner, tmp_path, monkeypatch):
|
|
108
|
+
global_path = tmp_path / "global.jsonc"
|
|
109
|
+
monkeypatch.setattr(
|
|
110
|
+
"evo_cli.commands.mcp.get_global_config_path",
|
|
111
|
+
lambda: global_path,
|
|
112
|
+
)
|
|
113
|
+
result = runner.invoke(cli, ["mcp", "add", "playwright", "--opencode-only"])
|
|
114
|
+
assert result.exit_code == 0
|
|
115
|
+
entry = load_jsonc(global_path)["mcp"]["playwright"]
|
|
116
|
+
assert entry["type"] == "local"
|
|
117
|
+
assert entry["command"] == ["npx", "-y", "@playwright/mcp@latest"]
|
|
118
|
+
|
|
119
|
+
|
|
78
120
|
def test_add_rejects_both_only_flags(runner, tmp_path, monkeypatch):
|
|
79
121
|
monkeypatch.setattr(
|
|
80
122
|
"evo_cli.commands.mcp.get_global_config_path",
|
|
@@ -10,6 +10,7 @@ from evo_cli.commands.opencode import (
|
|
|
10
10
|
configure_opencode_global,
|
|
11
11
|
configure_opencode_project,
|
|
12
12
|
install_mcp_servers,
|
|
13
|
+
install_opencode,
|
|
13
14
|
load_jsonc,
|
|
14
15
|
merge_mcp_config,
|
|
15
16
|
save_jsonc,
|
|
@@ -34,7 +35,7 @@ def test_opencode_help_runs(runner):
|
|
|
34
35
|
|
|
35
36
|
def test_load_jsonc_ignores_comments(tmp_path):
|
|
36
37
|
path = tmp_path / "config.jsonc"
|
|
37
|
-
path.write_text(
|
|
38
|
+
path.write_text('// header\n{"a": 1}\n// footer')
|
|
38
39
|
assert load_jsonc(path) == {"a": 1}
|
|
39
40
|
|
|
40
41
|
|
|
@@ -93,7 +94,37 @@ def test_install_mcp_servers_detaches_stdin_and_sets_timeout(monkeypatch):
|
|
|
93
94
|
assert kwargs.get("check") is False
|
|
94
95
|
|
|
95
96
|
|
|
96
|
-
def
|
|
97
|
+
def test_install_opencode_skips_when_present(monkeypatch):
|
|
98
|
+
monkeypatch.setattr("evo_cli.commands.opencode.shutil.which", lambda _: "/usr/bin/opencode")
|
|
99
|
+
monkeypatch.setattr(
|
|
100
|
+
"evo_cli.commands.opencode.subprocess.run",
|
|
101
|
+
lambda *a, **k: type("R", (), {"stdout": "1.2.3\n"})(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def fail_run_command(*a, **k):
|
|
105
|
+
raise AssertionError("run_command should not be called when opencode is present")
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr("evo_cli.commands.opencode.run_command", fail_run_command)
|
|
108
|
+
assert install_opencode("npm") is True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_install_opencode_runs_npm_when_missing(monkeypatch):
|
|
112
|
+
states = iter([None, "/usr/bin/opencode"]) # missing before install, present after
|
|
113
|
+
monkeypatch.setattr("evo_cli.commands.opencode.shutil.which", lambda _: next(states))
|
|
114
|
+
monkeypatch.setattr(
|
|
115
|
+
"evo_cli.commands.opencode.subprocess.run",
|
|
116
|
+
lambda *a, **k: type("R", (), {"stdout": "1.2.3\n"})(),
|
|
117
|
+
)
|
|
118
|
+
calls = []
|
|
119
|
+
monkeypatch.setattr(
|
|
120
|
+
"evo_cli.commands.opencode.run_command",
|
|
121
|
+
lambda cmd, **k: calls.append(cmd),
|
|
122
|
+
)
|
|
123
|
+
assert install_opencode("npm") is True
|
|
124
|
+
assert calls == [["npm", "install", "-g", "opencode-ai@latest"]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_setup_opencode_skip_install(runner, tmp_path, monkeypatch):
|
|
97
128
|
monkeypatch.setattr(
|
|
98
129
|
"evo_cli.commands.opencode.get_global_config_path",
|
|
99
130
|
lambda: tmp_path / "global.jsonc",
|
|
@@ -102,10 +133,31 @@ def test_setup_opencode_writes_global_and_project(runner, tmp_path, monkeypatch)
|
|
|
102
133
|
"evo_cli.commands.opencode.ensure_node_installed",
|
|
103
134
|
lambda: ("node", "npm", "npx"),
|
|
104
135
|
)
|
|
136
|
+
|
|
137
|
+
def fail_install_opencode(*a, **k):
|
|
138
|
+
raise AssertionError("install_opencode must not run with --skip-install")
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_opencode", fail_install_opencode)
|
|
105
141
|
monkeypatch.setattr("evo_cli.commands.opencode.install_mcp_servers", lambda: None)
|
|
142
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_playwright_browsers", lambda: None)
|
|
143
|
+
monkeypatch.setattr("evo_cli.commands.opencode.verify_mcp_servers", lambda: None)
|
|
144
|
+
|
|
145
|
+
result = runner.invoke(cli, ["setup", "opencode", "--global-only", "--skip-install"])
|
|
146
|
+
assert result.exit_code == 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_setup_opencode_writes_global_and_project(runner, tmp_path, monkeypatch):
|
|
106
150
|
monkeypatch.setattr(
|
|
107
|
-
"evo_cli.commands.opencode.
|
|
151
|
+
"evo_cli.commands.opencode.get_global_config_path",
|
|
152
|
+
lambda: tmp_path / "global.jsonc",
|
|
108
153
|
)
|
|
154
|
+
monkeypatch.setattr(
|
|
155
|
+
"evo_cli.commands.opencode.ensure_node_installed",
|
|
156
|
+
lambda: ("node", "npm", "npx"),
|
|
157
|
+
)
|
|
158
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_opencode", lambda *a, **k: True)
|
|
159
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_mcp_servers", lambda: None)
|
|
160
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_playwright_browsers", lambda: None)
|
|
109
161
|
monkeypatch.setattr("evo_cli.commands.opencode.verify_mcp_servers", lambda: None)
|
|
110
162
|
|
|
111
163
|
result = runner.invoke(cli, ["setup", "opencode", "--project", str(tmp_path)])
|
|
@@ -124,10 +176,9 @@ def test_setup_opencode_global_only(runner, tmp_path, monkeypatch):
|
|
|
124
176
|
"evo_cli.commands.opencode.ensure_node_installed",
|
|
125
177
|
lambda: ("node", "npm", "npx"),
|
|
126
178
|
)
|
|
179
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_opencode", lambda *a, **k: True)
|
|
127
180
|
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
|
-
)
|
|
181
|
+
monkeypatch.setattr("evo_cli.commands.opencode.install_playwright_browsers", lambda: None)
|
|
131
182
|
monkeypatch.setattr("evo_cli.commands.opencode.verify_mcp_servers", lambda: None)
|
|
132
183
|
|
|
133
184
|
project = tmp_path / "project"
|
evo_cli-0.9.1/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.9.1
|
|
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
|
|
File without changes
|
|
File without changes
|