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