evo-cli 0.9.1__tar.gz → 0.10.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 (45) hide show
  1. {evo_cli-0.9.1 → evo_cli-0.10.0}/PKG-INFO +1 -1
  2. evo_cli-0.10.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/mcp.py +55 -49
  4. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/opencode.py +74 -33
  5. evo_cli-0.10.0/evo_cli/mcp_registry.py +101 -0
  6. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/SOURCES.txt +1 -0
  8. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_mcp.py +42 -0
  9. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_opencode.py +57 -6
  10. evo_cli-0.9.1/evo_cli/VERSION +0 -1
  11. {evo_cli-0.9.1 → evo_cli-0.10.0}/Containerfile +0 -0
  12. {evo_cli-0.9.1 → evo_cli-0.10.0}/HISTORY.md +0 -0
  13. {evo_cli-0.9.1 → evo_cli-0.10.0}/LICENSE +0 -0
  14. {evo_cli-0.9.1 → evo_cli-0.10.0}/MANIFEST.in +0 -0
  15. {evo_cli-0.9.1 → evo_cli-0.10.0}/README.md +0 -0
  16. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/base.py +0 -0
  19. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/cli.py +0 -0
  20. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/__init__.py +0 -0
  21. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/cloudflare.py +0 -0
  22. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/fix_claude.py +0 -0
  23. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/gdrive.py +0 -0
  24. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/localproxy.py +0 -0
  25. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/miniconda.py +0 -0
  26. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/netcheck.py +0 -0
  27. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/plantuml.py +0 -0
  28. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/site2s.py +0 -0
  29. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/ssh.py +0 -0
  30. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/sysmon.py +0 -0
  31. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/commands/wifi.py +0 -0
  32. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli/console.py +0 -0
  33. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  34. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/entry_points.txt +0 -0
  35. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/requires.txt +0 -0
  36. {evo_cli-0.9.1 → evo_cli-0.10.0}/evo_cli.egg-info/top_level.txt +0 -0
  37. {evo_cli-0.9.1 → evo_cli-0.10.0}/pyproject.toml +0 -0
  38. {evo_cli-0.9.1 → evo_cli-0.10.0}/setup.cfg +0 -0
  39. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/__init__.py +0 -0
  40. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_cli.py +0 -0
  41. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_console.py +0 -0
  42. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_fix_claude.py +0 -0
  43. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_plantuml.py +0 -0
  44. {evo_cli-0.9.1 → evo_cli-0.10.0}/tests/test_sysmon.py +0 -0
  45. {evo_cli-0.9.1 → evo_cli-0.10.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.9.1
3
+ Version: 0.10.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.10.0
@@ -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 notion[/cyan]\n"
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-server --url https://example.com/mcp --transport http[/cyan]"
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["transport"] in ("http", "sse"):
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 known servers, or pass --url to add a custom one."
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 bundled in the registry."""
151
- table = Table(title="Known MCP servers", title_style="step", expand=False)
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("Transport", style="info")
154
- table.add_column("Endpoint", style="dim")
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
- table.add_row(name, spec["transport"], endpoint, spec["description"])
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
- "or a custom server with [accent]evo mcp add <name> --url <url>[/accent]."
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 registry).")
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 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.
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
@@ -11,6 +11,7 @@ from rich.panel import Panel
11
11
  from rich.text import Text
12
12
 
13
13
  from evo_cli.console import console, error, info, 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
- 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"],
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():
@@ -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
- servers = [
126
- "@mcp-server/google-search-mcp@latest",
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
- ["npx", "-y", server, "--version"],
133
- status=f"Fetching {server}",
125
+ [*command, "--version"],
126
+ status=f"Fetching {name}",
134
127
  stdin=subprocess.DEVNULL,
135
128
  timeout=180,
136
129
  check=False,
@@ -154,6 +147,45 @@ 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(["opencode", "--version"], capture_output=True, text=True, timeout=30)
156
+ return result.stdout.strip() or "unknown"
157
+ except Exception:
158
+ return "unknown"
159
+
160
+
161
+ def install_opencode(npm_cmd="npm"):
162
+ """Install the OpenCode CLI globally via npm if it is not already present."""
163
+ step("Installing OpenCode")
164
+ version = opencode_version()
165
+ if version is not None:
166
+ info(f"OpenCode already installed ({version}); skipping")
167
+ return True
168
+
169
+ try:
170
+ run_command(
171
+ [npm_cmd, "install", "-g", "opencode-ai@latest"],
172
+ status="Installing OpenCode via npm",
173
+ timeout=300,
174
+ )
175
+ except Exception as exc:
176
+ warning(f"Could not install OpenCode automatically: {exc}")
177
+ info("Install manually: npm i -g opencode-ai@latest (or curl -fsSL https://opencode.ai/install | bash)")
178
+ return False
179
+
180
+ version = opencode_version()
181
+ if version is None:
182
+ warning("OpenCode installed but `opencode` is not on PATH yet")
183
+ info("Open a new shell, or add your npm global bin directory to PATH")
184
+ return False
185
+ success(f"OpenCode installed ({version})")
186
+ return True
187
+
188
+
157
189
  def load_jsonc(path):
158
190
  """Load a JSONC file, stripping // comments.
159
191
 
@@ -258,11 +290,7 @@ def verify_mcp_servers():
258
290
  '"params":{"protocolVersion":"2024-11-05","capabilities":{},'
259
291
  '"clientInfo":{"name":"evo-cli","version":"1.0"}}}'
260
292
  )
261
- checks = [
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:
293
+ for name, cmd in _local_mcp_commands():
266
294
  try:
267
295
  result = subprocess.run(
268
296
  cmd,
@@ -279,15 +307,17 @@ def verify_mcp_servers():
279
307
  warning(f"Could not verify MCP server [accent]{name}[/accent]: {exc}")
280
308
 
281
309
 
282
- def run_setup_opencode(global_only, project):
310
+ def run_setup_opencode(global_only, project, skip_install=False):
283
311
  step("evo setup opencode")
284
312
 
285
313
  try:
286
- ensure_node_installed()
314
+ _, npm_cmd, _ = ensure_node_installed()
287
315
  except Exception as exc:
288
316
  error(str(exc))
289
317
  return
290
318
 
319
+ if not skip_install:
320
+ install_opencode(npm_cmd)
291
321
  install_mcp_servers()
292
322
  install_playwright_browsers()
293
323
 
@@ -304,10 +334,15 @@ def run_setup_opencode(global_only, project):
304
334
  if project_path:
305
335
  paths_text += f"\nProject: [accent]{project_path}[/accent]"
306
336
 
337
+ opencode_note = (
338
+ "Run `opencode` to start."
339
+ if opencode_version() is not None
340
+ else "Install OpenCode with: npm i -g opencode-ai@latest"
341
+ )
307
342
  console.print(
308
343
  Panel(
309
344
  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.",
345
+ f"{opencode_note} Restart OpenCode to load the new MCP tools.",
311
346
  title="setup opencode complete",
312
347
  border_style="success",
313
348
  expand=False,
@@ -321,19 +356,25 @@ def run_setup_opencode(global_only, project):
321
356
  is_flag=True,
322
357
  help="Only update the global OpenCode config; skip project-level config.",
323
358
  )
359
+ @click.option(
360
+ "--skip-install",
361
+ is_flag=True,
362
+ help="Skip installing the OpenCode CLI; only set up MCP servers and config.",
363
+ )
324
364
  @click.option(
325
365
  "--project",
326
366
  type=click.Path(file_okay=False, dir_okay=True, writable=True),
327
367
  help="Project directory to write opencode.json into. Defaults to the current directory.",
328
368
  )
329
- def setup_opencode(global_only, project):
330
- """Install Node.js (if needed), OpenCode MCP servers, and write config files.
369
+ def setup_opencode(global_only, skip_install, project):
370
+ """Install OpenCode and Node.js (if needed), its MCP servers, and write config files.
331
371
 
332
372
  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
373
+ used across devices. It installs the OpenCode CLI (via npm, unless
374
+ --skip-install) and the google-search and playwright MCP servers, downloads
375
+ the Playwright Chromium browser, and writes both global
335
376
  (~/.config/opencode/opencode.jsonc) and project-level (opencode.json) configs.
336
377
 
337
378
  No credentials are bundled; only public MCP server references are added.
338
379
  """
339
- run_setup_opencode(global_only, project)
380
+ run_setup_opencode(global_only, project, skip_install=skip_install)
@@ -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}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.9.1
3
+ Version: 0.10.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
@@ -10,6 +10,7 @@ evo_cli/__main__.py
10
10
  evo_cli/base.py
11
11
  evo_cli/cli.py
12
12
  evo_cli/console.py
13
+ evo_cli/mcp_registry.py
13
14
  evo_cli.egg-info/PKG-INFO
14
15
  evo_cli.egg-info/SOURCES.txt
15
16
  evo_cli.egg-info/dependency_links.txt
@@ -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("// header\n{\"a\": 1}\n// footer")
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 test_setup_opencode_writes_global_and_project(runner, tmp_path, monkeypatch):
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.install_playwright_browsers", lambda: None
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"
@@ -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