evo-cli 0.10.3__tar.gz → 0.11.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 (49) hide show
  1. {evo_cli-0.10.3 → evo_cli-0.11.0}/PKG-INFO +1 -1
  2. evo_cli-0.11.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/cli.py +5 -0
  4. evo_cli-0.11.0/evo_cli/commands/claude_code.py +309 -0
  5. evo_cli-0.11.0/evo_cli/commands/gh.py +246 -0
  6. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/SOURCES.txt +4 -0
  8. evo_cli-0.11.0/tests/test_claude_code.py +228 -0
  9. evo_cli-0.11.0/tests/test_gh.py +146 -0
  10. evo_cli-0.10.3/evo_cli/VERSION +0 -1
  11. {evo_cli-0.10.3 → evo_cli-0.11.0}/Containerfile +0 -0
  12. {evo_cli-0.10.3 → evo_cli-0.11.0}/HISTORY.md +0 -0
  13. {evo_cli-0.10.3 → evo_cli-0.11.0}/LICENSE +0 -0
  14. {evo_cli-0.10.3 → evo_cli-0.11.0}/MANIFEST.in +0 -0
  15. {evo_cli-0.10.3 → evo_cli-0.11.0}/README.md +0 -0
  16. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/base.py +0 -0
  19. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/fix_claude.py +0 -0
  22. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/gdrive.py +0 -0
  23. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/localproxy.py +0 -0
  24. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/mcp.py +0 -0
  25. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/miniconda.py +0 -0
  26. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/netcheck.py +0 -0
  27. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/opencode.py +0 -0
  28. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/plantuml.py +0 -0
  29. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/site2s.py +0 -0
  30. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/ssh.py +0 -0
  31. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/sysmon.py +0 -0
  32. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/commands/wifi.py +0 -0
  33. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/console.py +0 -0
  34. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli/mcp_registry.py +0 -0
  35. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  36. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/entry_points.txt +0 -0
  37. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/requires.txt +0 -0
  38. {evo_cli-0.10.3 → evo_cli-0.11.0}/evo_cli.egg-info/top_level.txt +0 -0
  39. {evo_cli-0.10.3 → evo_cli-0.11.0}/pyproject.toml +0 -0
  40. {evo_cli-0.10.3 → evo_cli-0.11.0}/setup.cfg +0 -0
  41. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/__init__.py +0 -0
  42. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_cli.py +0 -0
  43. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_console.py +0 -0
  44. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_fix_claude.py +0 -0
  45. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_mcp.py +0 -0
  46. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_opencode.py +0 -0
  47. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_plantuml.py +0 -0
  48. {evo_cli-0.10.3 → evo_cli-0.11.0}/tests/test_sysmon.py +0 -0
  49. {evo_cli-0.10.3 → evo_cli-0.11.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.10.3
3
+ Version: 0.11.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.11.0
@@ -1,9 +1,11 @@
1
1
  import rich_click as click
2
2
 
3
3
  from evo_cli import __version__
4
+ from evo_cli.commands.claude_code import setup_claude
4
5
  from evo_cli.commands.cloudflare import cfssh
5
6
  from evo_cli.commands.fix_claude import f_claude
6
7
  from evo_cli.commands.gdrive import gdrive
8
+ from evo_cli.commands.gh import setup_gh
7
9
  from evo_cli.commands.localproxy import localproxy
8
10
  from evo_cli.commands.mcp import mcp_group
9
11
  from evo_cli.commands.miniconda import miniconda
@@ -20,6 +22,7 @@ from evo_cli.commands.wifi import wifi
20
22
  def setup_group():
21
23
  """Commands for bootstrapping tools on a fresh machine."""
22
24
 
25
+
23
26
  click.rich_click.USE_MARKDOWN = True
24
27
  click.rich_click.SHOW_ARGUMENTS = True
25
28
  click.rich_click.STYLE_OPTIONS_TABLE_BOX = "SIMPLE"
@@ -41,6 +44,8 @@ def cli():
41
44
  """
42
45
 
43
46
 
47
+ setup_group.add_command(setup_claude)
48
+ setup_group.add_command(setup_gh)
44
49
  setup_group.add_command(setup_opencode)
45
50
  setup_group.add_command(miniconda)
46
51
  setup_group.add_command(setupssh)
@@ -0,0 +1,309 @@
1
+ """Install Claude Code and register its default MCP servers."""
2
+
3
+ import os
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.commands.fix_claude import is_affected, probe_version, version_str
14
+ from evo_cli.commands.gh import check_gh_auth, install_gh
15
+ from evo_cli.commands.mcp import add_to_claude
16
+ from evo_cli.console import CommandError, console, error, info, run_command, step, success, warning
17
+ from evo_cli.mcp_registry import MCP_REGISTRY
18
+
19
+ EPILOG = Text.from_markup(
20
+ "[bold]Examples[/bold]\n\n"
21
+ " [cyan]evo setup claude[/cyan] install Claude Code + gh + the default MCP servers\n"
22
+ " [cyan]evo setup claude --skip-install[/cyan] only register MCP servers\n"
23
+ " [cyan]evo setup claude --no-mcp --no-gh[/cyan] only install the CLI\n"
24
+ " [cyan]evo setup claude --mcp playwright --mcp github[/cyan]\n"
25
+ " [cyan]evo setup claude --method npm[/cyan] install via npm instead of the native installer\n"
26
+ " [cyan]evo setup claude --install-version 2.1.153[/cyan]\n"
27
+ " [cyan]evo setup claude --reinstall[/cyan] reinstall even if already present"
28
+ )
29
+
30
+ INSTALL_SH = "https://claude.ai/install.sh"
31
+ INSTALL_PS1 = "https://claude.ai/install.ps1"
32
+ NPM_PACKAGE = "@anthropic-ai/claude-code"
33
+
34
+ # Claude Code already ships web search, file editing and shell tools, so the
35
+ # defaults only cover what it cannot do on its own: drive a real browser
36
+ # (playwright) and pull current library docs (context7). Add any other server
37
+ # from the library later with `evo mcp add <name>`.
38
+ DEFAULT_MCP_SERVERS = ("playwright", "context7")
39
+
40
+ # The native installer drops the binary in one of these; neither is on PATH until
41
+ # a new shell is started.
42
+ LOCAL_BIN_DIRS = (
43
+ Path.home() / ".local" / "bin",
44
+ Path.home() / ".claude" / "local",
45
+ )
46
+
47
+
48
+ def is_windows():
49
+ return platform.system() == "Windows"
50
+
51
+
52
+ def ensure_claude_on_path():
53
+ """Return the `claude` binary, making a fresh install visible to this process.
54
+
55
+ The native installer writes to ~/.local/bin, which the shell that launched
56
+ evo has not picked up yet, so `claude` stays invisible until the user opens a
57
+ new terminal. Prepend the install dir to PATH so the MCP and doctor steps can
58
+ still run in this same invocation.
59
+ """
60
+ found = shutil.which("claude")
61
+ if found:
62
+ return found
63
+
64
+ binary = "claude.exe" if is_windows() else "claude"
65
+ for directory in LOCAL_BIN_DIRS:
66
+ candidate = directory / binary
67
+ if candidate.exists():
68
+ os.environ["PATH"] = f"{directory}{os.pathsep}{os.environ.get('PATH', '')}"
69
+ return str(candidate)
70
+ return None
71
+
72
+
73
+ def native_install_command(version=None):
74
+ """Build the official installer command for this platform."""
75
+ if is_windows():
76
+ return ["powershell", "-NoProfile", "-Command", f"irm {INSTALL_PS1} | iex"]
77
+ script = f"curl -fsSL {INSTALL_SH} | bash"
78
+ if version:
79
+ script += f" -s {version}"
80
+ return ["bash", "-c", script]
81
+
82
+
83
+ def npm_install_command(version=None):
84
+ package = f"{NPM_PACKAGE}@{version}" if version else NPM_PACKAGE
85
+ return ["npm", "install", "-g", package]
86
+
87
+
88
+ def native_install_available(version=None):
89
+ """Whether the native installer can run here.
90
+
91
+ install.ps1 takes no version argument, so a pinned version on Windows has to
92
+ go through npm instead.
93
+ """
94
+ if is_windows():
95
+ return bool(shutil.which("powershell")) and not version
96
+ return bool(shutil.which("curl") and shutil.which("bash"))
97
+
98
+
99
+ def install_attempts(method, version):
100
+ """Ordered (label, command) install attempts for the requested method."""
101
+ attempts = []
102
+ if method in ("auto", "native") and native_install_available(version):
103
+ attempts.append(("native installer", native_install_command(version)))
104
+ if method in ("auto", "npm") and shutil.which("npm"):
105
+ attempts.append(("npm", npm_install_command(version)))
106
+ return attempts
107
+
108
+
109
+ def install_claude(method="auto", version=None, reinstall=False):
110
+ """Install the Claude Code CLI. Returns True when `claude` ends up runnable."""
111
+ step("Installing Claude Code")
112
+
113
+ if ensure_claude_on_path() and not reinstall:
114
+ info(f"Claude Code already installed ({version_str(probe_version())}); skipping install")
115
+ info("Pass --reinstall to install it again, or run `claude update` to update.")
116
+ return True
117
+
118
+ attempts = install_attempts(method, version)
119
+ if not attempts:
120
+ error(f"No usable install method for --method {method} on {platform.system()}.")
121
+ if is_windows():
122
+ info(f"Install manually: [accent]irm {INSTALL_PS1} | iex[/accent] (PowerShell)")
123
+ else:
124
+ info(f"Install manually: [accent]curl -fsSL {INSTALL_SH} | bash[/accent]")
125
+ return False
126
+
127
+ for label, command in attempts:
128
+ info(f"Installing via {label}")
129
+ try:
130
+ run_command(command, status=f"Installing Claude Code via {label}", timeout=600)
131
+ except CommandError as exc:
132
+ warning(f"Install via {label} failed: {exc}")
133
+ continue
134
+
135
+ if ensure_claude_on_path():
136
+ success(f"Claude Code installed ({version_str(probe_version())})")
137
+ return True
138
+ warning(f"Install via {label} finished but `claude` is not on PATH")
139
+
140
+ error("Could not install Claude Code.")
141
+ info("Open a new shell and retry, or add the install directory (~/.local/bin) to PATH.")
142
+ return False
143
+
144
+
145
+ def configure_mcp_servers(names, scope):
146
+ """Register the named library MCP servers with Claude Code."""
147
+ added = []
148
+ for name in names:
149
+ if add_to_claude(name, MCP_REGISTRY[name], scope):
150
+ added.append(name)
151
+ return added
152
+
153
+
154
+ def run_doctor():
155
+ """Run Claude Code's own diagnostic. Never fatal: it is a report, not a gate."""
156
+ step("Verifying installation")
157
+ result = run_command(
158
+ ["claude", "doctor"],
159
+ status="Running claude doctor",
160
+ stdin=subprocess.DEVNULL,
161
+ timeout=120,
162
+ check=False,
163
+ )
164
+ if getattr(result, "returncode", None) == 0:
165
+ success("`claude doctor` reported no problems")
166
+ else:
167
+ warning("`claude doctor` reported problems; see its output above")
168
+
169
+
170
+ def setup_github_cli():
171
+ """Install gh and report its auth state. Returns one of: ready, unauthenticated, missing.
172
+
173
+ Claude Code shells out to `gh` for PRs, issues and the GitHub API, so a fresh
174
+ machine wants it too. A failure here is not fatal: Claude Code still works,
175
+ it just cannot touch GitHub until gh is installed and signed in.
176
+ """
177
+ if not install_gh():
178
+ return "missing"
179
+ return "ready" if check_gh_auth() else "unauthenticated"
180
+
181
+
182
+ def print_summary(installed, added, scope, gh_state=None):
183
+ console.print()
184
+ if not installed:
185
+ console.print(
186
+ Panel(
187
+ "Claude Code is not installed, so nothing was configured.\n\n"
188
+ f"Install it with [accent]curl -fsSL {INSTALL_SH} | bash[/accent] "
189
+ "(or [accent]evo setup claude --method npm[/accent]), then re-run this command.",
190
+ title="setup claude incomplete",
191
+ border_style="warning",
192
+ expand=False,
193
+ )
194
+ )
195
+ return
196
+
197
+ version = probe_version()
198
+ lines = [f"Claude Code [accent]{version_str(version)}[/accent] is ready."]
199
+ if added:
200
+ lines.append(f"MCP servers registered ({scope} scope): [accent]{', '.join(added)}[/accent]")
201
+ lines.append("Run [accent]/mcp[/accent] inside Claude Code to authenticate any server that needs OAuth.")
202
+ lines.append("")
203
+ lines.append("Run [accent]claude[/accent] to start; the first run opens a browser to sign in.")
204
+ lines.append(
205
+ "Add more MCP servers anytime with [accent]evo mcp add <name>[/accent] ([accent]evo mcp list[/accent])."
206
+ )
207
+ if gh_state == "unauthenticated":
208
+ lines.append("Sign in to GitHub with [accent]gh auth login[/accent] so Claude Code can open PRs.")
209
+ elif gh_state == "missing":
210
+ lines.append("[warning]GitHub CLI is missing; retry with [accent]evo setup gh[/accent].[/warning]")
211
+ if is_affected(version):
212
+ lines.append("")
213
+ lines.append(
214
+ "[warning]This build has the tool-result bug; run [accent]evo f-claude[/accent] to fix it.[/warning]"
215
+ )
216
+
217
+ console.print(
218
+ Panel(
219
+ Text.from_markup("\n".join(lines)),
220
+ title="setup claude complete",
221
+ border_style="success",
222
+ expand=False,
223
+ )
224
+ )
225
+
226
+
227
+ def run_setup_claude(skip_install, reinstall, method, install_version, mcp_names, no_mcp, scope, no_gh):
228
+ step("evo setup claude")
229
+
230
+ if skip_install:
231
+ info("Skipping install as requested")
232
+ installed = bool(ensure_claude_on_path())
233
+ if not installed:
234
+ error("`claude` was not found on PATH; nothing to configure.")
235
+ else:
236
+ installed = install_claude(method, install_version, reinstall)
237
+
238
+ gh_state = None
239
+ if no_gh:
240
+ info("Skipping the GitHub CLI as requested")
241
+ else:
242
+ gh_state = setup_github_cli()
243
+
244
+ added = []
245
+ if not installed:
246
+ pass
247
+ elif no_mcp:
248
+ info("Skipping MCP registration as requested")
249
+ else:
250
+ added = configure_mcp_servers(mcp_names or DEFAULT_MCP_SERVERS, scope)
251
+
252
+ if installed:
253
+ run_doctor()
254
+
255
+ print_summary(installed, added, scope, gh_state)
256
+
257
+
258
+ @click.command("claude", epilog=EPILOG)
259
+ @click.option("--skip-install", is_flag=True, help="Skip installing the CLI; only register MCP servers.")
260
+ @click.option("--reinstall", is_flag=True, help="Install even if Claude Code is already present.")
261
+ @click.option(
262
+ "--method",
263
+ type=click.Choice(["auto", "native", "npm"]),
264
+ default="auto",
265
+ show_default=True,
266
+ help="Install method. `auto` tries the native installer, then falls back to npm.",
267
+ )
268
+ @click.option(
269
+ "--install-version",
270
+ metavar="VERSION",
271
+ help="Install a specific version instead of the latest, e.g. 2.1.153.",
272
+ )
273
+ @click.option(
274
+ "--mcp",
275
+ "mcp_names",
276
+ multiple=True,
277
+ metavar="NAME",
278
+ help=f"MCP server from the library to register (repeatable). Default: {', '.join(DEFAULT_MCP_SERVERS)}.",
279
+ )
280
+ @click.option("--no-mcp", is_flag=True, help="Skip MCP server registration.")
281
+ @click.option("--no-gh", is_flag=True, help="Skip installing the GitHub CLI (gh).")
282
+ @click.option(
283
+ "--scope",
284
+ type=click.Choice(["local", "user", "project"]),
285
+ default="user",
286
+ show_default=True,
287
+ help="Claude Code config scope for the MCP servers.",
288
+ )
289
+ def setup_claude(skip_install, reinstall, method, install_version, mcp_names, no_mcp, no_gh, scope):
290
+ """Install Claude Code, the GitHub CLI, and the default MCP servers.
291
+
292
+ Installs the CLI with Anthropic's native installer (falling back to npm),
293
+ installs the GitHub CLI that Claude Code shells out to for PRs and issues,
294
+ registers the MCP servers Claude Code lacks natively - playwright for browser
295
+ automation and context7 for up-to-date library docs - and runs `claude doctor`
296
+ to verify the result. Everything is idempotent: existing installs are left
297
+ alone unless --reinstall is passed, and servers already registered are skipped.
298
+
299
+ Pick your own servers with --mcp (repeatable, from `evo mcp list`), or skip
300
+ them with --no-mcp; skip the GitHub CLI with --no-gh (`evo setup gh` installs
301
+ it on its own). No credentials are written: `gh auth login` and OAuth MCP
302
+ servers (/mcp inside Claude Code) stay in your hands.
303
+ """
304
+ unknown = [name for name in mcp_names if name not in MCP_REGISTRY]
305
+ if unknown:
306
+ error(f"Unknown MCP server(s): {', '.join(unknown)}. Run `evo mcp list` to see the library.")
307
+ return
308
+
309
+ run_setup_claude(skip_install, reinstall, method, install_version, mcp_names, no_mcp, scope, no_gh)
@@ -0,0 +1,246 @@
1
+ """Install the GitHub CLI (gh) and report whether it is authenticated."""
2
+
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+
8
+ import rich_click as click
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from evo_cli.console import CommandError, console, error, info, resolve_executable, run_command, step, success, warning
13
+
14
+ EPILOG = Text.from_markup(
15
+ "[bold]Examples[/bold]\n\n"
16
+ " [cyan]evo setup gh[/cyan] install gh, then check its auth status\n"
17
+ " [cyan]evo setup gh --reinstall[/cyan] install again even if gh is present\n"
18
+ " [cyan]evo setup gh --skip-auth-check[/cyan] install only; do not run `gh auth status`"
19
+ )
20
+
21
+ RELEASES_URL = "https://github.com/cli/cli/releases/latest"
22
+
23
+ # gh is not in the default Debian/Ubuntu repositories, so the official
24
+ # instructions add GitHub's keyring and apt source first. Kept close to upstream:
25
+ # https://github.com/cli/cli/blob/trunk/docs/install_linux.md
26
+ APT_SCRIPT = (
27
+ "set -e\n"
28
+ "(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y))\n"
29
+ "sudo mkdir -p -m 755 /etc/apt/keyrings\n"
30
+ "out=$(mktemp)\n"
31
+ "wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg\n"
32
+ "cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null\n"
33
+ "sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg\n"
34
+ "sudo mkdir -p -m 755 /etc/apt/sources.list.d\n"
35
+ 'echo "deb [arch=$(dpkg --print-architecture) '
36
+ "signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] "
37
+ 'https://cli.github.com/packages stable main" '
38
+ "| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null\n"
39
+ "sudo apt update\n"
40
+ "sudo apt install gh -y\n"
41
+ )
42
+
43
+ # Fedora ships gh in its own repositories; only fall back to GitHub's rpm repo
44
+ # when the distro does not carry the package.
45
+ DNF_SCRIPT = (
46
+ "sudo dnf install -y gh || {\n"
47
+ " sudo dnf install -y dnf5-plugins\n"
48
+ " sudo dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo\n"
49
+ " sudo dnf install -y gh\n"
50
+ "}\n"
51
+ )
52
+
53
+ ZYPPER_SCRIPT = (
54
+ "set -e\n"
55
+ "sudo zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo\n"
56
+ "sudo zypper ref\n"
57
+ "sudo zypper install -y gh\n"
58
+ )
59
+
60
+
61
+ def gh_version():
62
+ """Return the installed gh version string, or None if gh is not on PATH."""
63
+ if not shutil.which("gh"):
64
+ return None
65
+ try:
66
+ result = subprocess.run(
67
+ resolve_executable(["gh", "--version"]),
68
+ capture_output=True,
69
+ text=True,
70
+ encoding="utf-8",
71
+ errors="replace",
72
+ timeout=30,
73
+ )
74
+ except (OSError, subprocess.SubprocessError):
75
+ return "unknown"
76
+ match = re.search(r"\d+\.\d+\.\d+", result.stdout or "")
77
+ return match.group(0) if match else "unknown"
78
+
79
+
80
+ def linux_install_command():
81
+ if shutil.which("apt-get"):
82
+ return ["bash", "-c", APT_SCRIPT]
83
+ if shutil.which("dnf"):
84
+ return ["bash", "-c", DNF_SCRIPT]
85
+ if shutil.which("pacman"):
86
+ return ["sudo", "pacman", "-S", "--noconfirm", "github-cli"]
87
+ if shutil.which("apk"):
88
+ return ["sudo", "apk", "add", "github-cli"]
89
+ if shutil.which("zypper"):
90
+ return ["bash", "-c", ZYPPER_SCRIPT]
91
+ return None
92
+
93
+
94
+ def windows_install_command():
95
+ if shutil.which("winget"):
96
+ # The accept flags keep winget from stopping on an interactive agreement prompt.
97
+ return [
98
+ "winget",
99
+ "install",
100
+ "--id",
101
+ "GitHub.cli",
102
+ "--source",
103
+ "winget",
104
+ "--accept-package-agreements",
105
+ "--accept-source-agreements",
106
+ ]
107
+ if shutil.which("scoop"):
108
+ return ["scoop", "install", "gh"]
109
+ if shutil.which("choco"):
110
+ return ["choco", "install", "gh", "-y"]
111
+ return None
112
+
113
+
114
+ def install_command():
115
+ """The gh install command for this platform, or None if none is usable."""
116
+ system = platform.system()
117
+ if system == "Darwin":
118
+ return ["brew", "install", "gh"] if shutil.which("brew") else None
119
+ if system == "Linux":
120
+ return linux_install_command()
121
+ if system == "Windows":
122
+ return windows_install_command()
123
+ return None
124
+
125
+
126
+ def missing_package_manager_hint():
127
+ system = platform.system()
128
+ if system == "Darwin":
129
+ info("Homebrew not found. Install it from https://brew.sh, then re-run.")
130
+ elif system == "Linux":
131
+ info("No supported package manager found (apt-get, dnf, pacman, apk, zypper).")
132
+ elif system == "Windows":
133
+ info("No supported package manager found (winget, scoop, choco).")
134
+ else:
135
+ info(f"Unsupported platform: {system}")
136
+ info(f"You can also grab a binary from [accent]{RELEASES_URL}[/accent]")
137
+
138
+
139
+ def install_gh(reinstall=False):
140
+ """Install the GitHub CLI. Returns True when `gh` ends up runnable."""
141
+ step("Installing GitHub CLI")
142
+
143
+ version = gh_version()
144
+ if version and not reinstall:
145
+ info(f"gh already installed ({version}); skipping install")
146
+ return True
147
+
148
+ command = install_command()
149
+ if command is None:
150
+ error("Could not install gh automatically.")
151
+ missing_package_manager_hint()
152
+ return False
153
+
154
+ try:
155
+ run_command(command, status="Installing gh", stdin=subprocess.DEVNULL, timeout=900)
156
+ except CommandError as exc:
157
+ error(f"Installing gh failed: {exc}")
158
+ info(f"Install it manually from [accent]{RELEASES_URL}[/accent]")
159
+ return False
160
+
161
+ version = gh_version()
162
+ if not version:
163
+ warning("gh installed but it is not on PATH yet")
164
+ info("Open a new terminal, then run `gh --version` to confirm.")
165
+ return False
166
+
167
+ success(f"gh installed ({version})")
168
+ return True
169
+
170
+
171
+ def check_gh_auth():
172
+ """Report whether gh holds GitHub credentials.
173
+
174
+ `gh auth status` is read-only and exits non-zero when logged out, so it is
175
+ safe here. `gh auth login` is not: it needs a TTY and a browser, so leave it
176
+ to the user rather than blocking the run on it.
177
+ """
178
+ step("Checking GitHub authentication")
179
+ result = run_command(
180
+ ["gh", "auth", "status"],
181
+ status="Running gh auth status",
182
+ stdin=subprocess.DEVNULL,
183
+ timeout=60,
184
+ check=False,
185
+ )
186
+ if getattr(result, "returncode", None) == 0:
187
+ success("gh is authenticated")
188
+ return True
189
+
190
+ warning("gh is not authenticated")
191
+ info("Run [accent]gh auth login[/accent] in your terminal; it opens a browser to sign in.")
192
+ return False
193
+
194
+
195
+ def print_summary(installed, authenticated, auth_checked):
196
+ console.print()
197
+ if not installed:
198
+ console.print(
199
+ Panel(
200
+ f"gh was not installed. Install it manually from [accent]{RELEASES_URL}[/accent], then re-run.",
201
+ title="setup gh incomplete",
202
+ border_style="warning",
203
+ expand=False,
204
+ )
205
+ )
206
+ return
207
+
208
+ lines = [f"GitHub CLI [accent]{gh_version()}[/accent] is ready."]
209
+ if not auth_checked:
210
+ lines.append("Auth check skipped; run [accent]gh auth status[/accent] to see if you are signed in.")
211
+ elif authenticated:
212
+ lines.append("You are signed in; `gh pr`, `gh issue` and Claude Code's GitHub workflows will work.")
213
+ else:
214
+ lines.append("Sign in with [accent]gh auth login[/accent] to finish setting it up.")
215
+
216
+ console.print(
217
+ Panel(
218
+ "\n".join(lines),
219
+ title="setup gh complete",
220
+ border_style="success",
221
+ expand=False,
222
+ )
223
+ )
224
+
225
+
226
+ @click.command("gh", epilog=EPILOG)
227
+ @click.option("--reinstall", is_flag=True, help="Install even if gh is already present.")
228
+ @click.option("--skip-auth-check", is_flag=True, help="Skip the `gh auth status` check afterwards.")
229
+ def setup_gh(reinstall, skip_auth_check):
230
+ """Install the GitHub CLI (gh) and check whether it is signed in.
231
+
232
+ Uses the package manager for your platform - Homebrew on macOS, apt/dnf/
233
+ pacman/apk/zypper on Linux, winget/scoop/choco on Windows - following
234
+ GitHub's official instructions (on Debian/Ubuntu that means adding GitHub's
235
+ keyring and apt source, since gh is not in the default repos).
236
+
237
+ An existing install is left alone unless --reinstall is passed. Signing in is
238
+ left to you: `gh auth login` needs an interactive terminal and a browser, so
239
+ this command only reports whether credentials are already present.
240
+ """
241
+ step("evo setup gh")
242
+ installed = install_gh(reinstall)
243
+ authenticated = False
244
+ if installed and not skip_auth_check:
245
+ authenticated = check_gh_auth()
246
+ print_summary(installed, authenticated, auth_checked=not skip_auth_check)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.10.3
3
+ Version: 0.11.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
@@ -18,9 +18,11 @@ evo_cli.egg-info/entry_points.txt
18
18
  evo_cli.egg-info/requires.txt
19
19
  evo_cli.egg-info/top_level.txt
20
20
  evo_cli/commands/__init__.py
21
+ evo_cli/commands/claude_code.py
21
22
  evo_cli/commands/cloudflare.py
22
23
  evo_cli/commands/fix_claude.py
23
24
  evo_cli/commands/gdrive.py
25
+ evo_cli/commands/gh.py
24
26
  evo_cli/commands/localproxy.py
25
27
  evo_cli/commands/mcp.py
26
28
  evo_cli/commands/miniconda.py
@@ -32,9 +34,11 @@ evo_cli/commands/ssh.py
32
34
  evo_cli/commands/sysmon.py
33
35
  evo_cli/commands/wifi.py
34
36
  tests/__init__.py
37
+ tests/test_claude_code.py
35
38
  tests/test_cli.py
36
39
  tests/test_console.py
37
40
  tests/test_fix_claude.py
41
+ tests/test_gh.py
38
42
  tests/test_mcp.py
39
43
  tests/test_opencode.py
40
44
  tests/test_plantuml.py
@@ -0,0 +1,228 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+
4
+ from evo_cli.cli import cli
5
+ from evo_cli.commands import claude_code
6
+ from evo_cli.commands.claude_code import (
7
+ DEFAULT_MCP_SERVERS,
8
+ configure_mcp_servers,
9
+ install_attempts,
10
+ install_claude,
11
+ native_install_command,
12
+ npm_install_command,
13
+ )
14
+ from evo_cli.mcp_registry import MCP_REGISTRY
15
+
16
+
17
+ @pytest.fixture
18
+ def runner():
19
+ return CliRunner()
20
+
21
+
22
+ def test_claude_command_is_registered():
23
+ assert "setup" in cli.commands
24
+ assert "claude" in cli.commands["setup"].commands
25
+
26
+
27
+ def test_claude_help_runs(runner):
28
+ result = runner.invoke(cli, ["setup", "claude", "--help"])
29
+ assert result.exit_code == 0
30
+ assert "Claude Code" in result.output
31
+
32
+
33
+ def test_default_mcp_servers_are_in_the_library():
34
+ for name in DEFAULT_MCP_SERVERS:
35
+ assert name in MCP_REGISTRY
36
+
37
+
38
+ def test_native_install_command_posix(monkeypatch):
39
+ monkeypatch.setattr(claude_code, "is_windows", lambda: False)
40
+ assert native_install_command() == ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"]
41
+
42
+
43
+ def test_native_install_command_pins_version(monkeypatch):
44
+ monkeypatch.setattr(claude_code, "is_windows", lambda: False)
45
+ assert native_install_command("2.1.153")[-1].endswith("| bash -s 2.1.153")
46
+
47
+
48
+ def test_native_install_command_windows(monkeypatch):
49
+ monkeypatch.setattr(claude_code, "is_windows", lambda: True)
50
+ command = native_install_command()
51
+ assert command[0] == "powershell"
52
+ assert "install.ps1" in command[-1]
53
+
54
+
55
+ def test_npm_install_command():
56
+ assert npm_install_command() == ["npm", "install", "-g", "@anthropic-ai/claude-code"]
57
+ assert npm_install_command("2.1.153")[-1] == "@anthropic-ai/claude-code@2.1.153"
58
+
59
+
60
+ def test_install_attempts_auto_falls_back_to_npm(monkeypatch):
61
+ """With the native installer unusable, `auto` still has npm to fall back on."""
62
+ monkeypatch.setattr(claude_code, "native_install_available", lambda version=None: False)
63
+ monkeypatch.setattr(claude_code.shutil, "which", lambda name: "/usr/bin/npm")
64
+ labels = [label for label, _ in install_attempts("auto", None)]
65
+ assert labels == ["npm"]
66
+
67
+
68
+ def test_install_attempts_native_only_skips_npm(monkeypatch):
69
+ monkeypatch.setattr(claude_code, "native_install_available", lambda version=None: True)
70
+ monkeypatch.setattr(claude_code, "is_windows", lambda: False)
71
+ labels = [label for label, _ in install_attempts("native", None)]
72
+ assert labels == ["native installer"]
73
+
74
+
75
+ def test_native_install_unavailable_when_pinning_on_windows(monkeypatch):
76
+ """install.ps1 takes no version argument, so a pinned version must go via npm."""
77
+ monkeypatch.setattr(claude_code, "is_windows", lambda: True)
78
+ monkeypatch.setattr(claude_code.shutil, "which", lambda name: "/usr/bin/powershell")
79
+ assert claude_code.native_install_available() is True
80
+ assert claude_code.native_install_available("2.1.153") is False
81
+
82
+
83
+ def test_install_claude_skips_when_already_present(monkeypatch):
84
+ calls = []
85
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
86
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 153))
87
+ monkeypatch.setattr(claude_code, "run_command", lambda *a, **kw: calls.append(a))
88
+
89
+ assert install_claude() is True
90
+ assert calls == []
91
+
92
+
93
+ def test_install_claude_reinstall_runs_the_installer(monkeypatch):
94
+ calls = []
95
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
96
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 153))
97
+ monkeypatch.setattr(claude_code, "install_attempts", lambda m, v: [("npm", ["npm", "install"])])
98
+ monkeypatch.setattr(claude_code, "run_command", lambda cmd, **kw: calls.append(cmd))
99
+
100
+ assert install_claude(reinstall=True) is True
101
+ assert calls == [["npm", "install"]]
102
+
103
+
104
+ def test_install_claude_falls_back_after_a_failure(monkeypatch):
105
+ """A failing native install must not abort the run; npm is still tried."""
106
+ calls = []
107
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: None if not calls else "/usr/bin/claude")
108
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 153))
109
+ monkeypatch.setattr(
110
+ claude_code,
111
+ "install_attempts",
112
+ lambda m, v: [("native installer", ["bash", "-c", "boom"]), ("npm", ["npm", "install"])],
113
+ )
114
+
115
+ def fake_run(cmd, **kwargs):
116
+ calls.append(cmd)
117
+ if cmd[0] == "bash":
118
+ raise claude_code.CommandError("native failed")
119
+
120
+ monkeypatch.setattr(claude_code, "run_command", fake_run)
121
+
122
+ assert install_claude() is True
123
+ assert calls == [["bash", "-c", "boom"], ["npm", "install"]]
124
+
125
+
126
+ def test_install_claude_reports_failure_when_nothing_works(monkeypatch):
127
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: None)
128
+ monkeypatch.setattr(claude_code, "install_attempts", lambda m, v: [])
129
+ assert install_claude() is False
130
+
131
+
132
+ def test_configure_mcp_servers_passes_registry_specs(monkeypatch):
133
+ seen = []
134
+
135
+ def fake_add(name, spec, scope):
136
+ seen.append((name, spec, scope))
137
+ return True
138
+
139
+ monkeypatch.setattr(claude_code, "add_to_claude", fake_add)
140
+ added = configure_mcp_servers(["playwright"], "user")
141
+
142
+ assert added == ["playwright"]
143
+ assert seen == [("playwright", MCP_REGISTRY["playwright"], "user")]
144
+
145
+
146
+ def test_configure_mcp_servers_omits_failures(monkeypatch):
147
+ monkeypatch.setattr(claude_code, "add_to_claude", lambda name, spec, scope: name != "context7")
148
+ assert configure_mcp_servers(["playwright", "context7"], "user") == ["playwright"]
149
+
150
+
151
+ def test_unknown_mcp_server_is_rejected(runner, monkeypatch):
152
+ monkeypatch.setattr(claude_code, "run_setup_claude", lambda *a, **kw: pytest.fail("must not run"))
153
+ result = runner.invoke(cli, ["setup", "claude", "--mcp", "nope"])
154
+ assert result.exit_code == 0
155
+ assert "Unknown MCP server" in result.output
156
+
157
+
158
+ def test_skip_install_without_claude_does_nothing(runner, monkeypatch):
159
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: None)
160
+ monkeypatch.setattr(claude_code, "configure_mcp_servers", lambda *a: pytest.fail("must not configure"))
161
+ monkeypatch.setattr(claude_code, "run_doctor", lambda: pytest.fail("must not run doctor"))
162
+
163
+ result = runner.invoke(cli, ["setup", "claude", "--skip-install", "--no-gh"])
164
+ assert result.exit_code == 0
165
+ assert "not found on PATH" in result.output
166
+
167
+
168
+ def test_no_mcp_skips_registration(runner, monkeypatch):
169
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
170
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 200))
171
+ monkeypatch.setattr(claude_code, "configure_mcp_servers", lambda *a: pytest.fail("must not configure"))
172
+ monkeypatch.setattr(claude_code, "run_doctor", lambda: None)
173
+
174
+ result = runner.invoke(cli, ["setup", "claude", "--skip-install", "--no-mcp", "--no-gh"])
175
+ assert result.exit_code == 0
176
+ assert "Skipping MCP registration" in result.output
177
+
178
+
179
+ def test_setup_installs_gh_by_default(runner, monkeypatch):
180
+ """Claude Code shells out to gh for PRs, so a plain run must set it up too."""
181
+ calls = []
182
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
183
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 200))
184
+ monkeypatch.setattr(claude_code, "install_gh", lambda: calls.append("install") or True)
185
+ monkeypatch.setattr(claude_code, "check_gh_auth", lambda: calls.append("auth") or True)
186
+ monkeypatch.setattr(claude_code, "run_doctor", lambda: None)
187
+
188
+ result = runner.invoke(cli, ["setup", "claude", "--skip-install", "--no-mcp"])
189
+ assert result.exit_code == 0
190
+ assert calls == ["install", "auth"]
191
+
192
+
193
+ def test_no_gh_skips_the_github_cli(runner, monkeypatch):
194
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
195
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 200))
196
+ monkeypatch.setattr(claude_code, "install_gh", lambda: pytest.fail("must not install gh"))
197
+ monkeypatch.setattr(claude_code, "run_doctor", lambda: None)
198
+
199
+ result = runner.invoke(cli, ["setup", "claude", "--skip-install", "--no-mcp", "--no-gh"])
200
+ assert result.exit_code == 0
201
+ assert "Skipping the GitHub CLI" in result.output
202
+
203
+
204
+ def test_setup_github_cli_states(monkeypatch):
205
+ monkeypatch.setattr(claude_code, "install_gh", lambda: False)
206
+ monkeypatch.setattr(claude_code, "check_gh_auth", lambda: pytest.fail("must not check a missing gh"))
207
+ assert claude_code.setup_github_cli() == "missing"
208
+
209
+ monkeypatch.setattr(claude_code, "install_gh", lambda: True)
210
+ monkeypatch.setattr(claude_code, "check_gh_auth", lambda: False)
211
+ assert claude_code.setup_github_cli() == "unauthenticated"
212
+
213
+ monkeypatch.setattr(claude_code, "check_gh_auth", lambda: True)
214
+ assert claude_code.setup_github_cli() == "ready"
215
+
216
+
217
+ def test_failed_gh_install_does_not_block_claude_setup(runner, monkeypatch):
218
+ """gh is a companion tool: losing it must not fail the Claude Code setup."""
219
+ monkeypatch.setattr(claude_code, "ensure_claude_on_path", lambda: "/usr/bin/claude")
220
+ monkeypatch.setattr(claude_code, "probe_version", lambda: (2, 1, 200))
221
+ monkeypatch.setattr(claude_code, "install_gh", lambda: False)
222
+ monkeypatch.setattr(claude_code, "configure_mcp_servers", lambda *a: ["playwright"])
223
+ monkeypatch.setattr(claude_code, "run_doctor", lambda: None)
224
+
225
+ result = runner.invoke(cli, ["setup", "claude", "--skip-install"])
226
+ assert result.exit_code == 0
227
+ assert "setup claude complete" in result.output
228
+ assert "evo setup gh" in result.output
@@ -0,0 +1,146 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+
4
+ from evo_cli.cli import cli
5
+ from evo_cli.commands import gh as gh_mod
6
+ from evo_cli.commands.gh import check_gh_auth, gh_version, install_command, install_gh
7
+
8
+
9
+ @pytest.fixture
10
+ def runner():
11
+ return CliRunner()
12
+
13
+
14
+ def only_which(*available):
15
+ """A shutil.which stub that finds only the named binaries."""
16
+ return lambda name: f"/usr/bin/{name}" if name in available else None
17
+
18
+
19
+ def test_gh_command_is_registered():
20
+ assert "gh" in cli.commands["setup"].commands
21
+
22
+
23
+ def test_gh_help_runs(runner):
24
+ result = runner.invoke(cli, ["setup", "gh", "--help"])
25
+ assert result.exit_code == 0
26
+ assert "GitHub CLI" in result.output
27
+
28
+
29
+ def test_install_command_macos_uses_brew(monkeypatch):
30
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
31
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which("brew"))
32
+ assert install_command() == ["brew", "install", "gh"]
33
+
34
+
35
+ def test_install_command_macos_without_brew(monkeypatch):
36
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
37
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which())
38
+ assert install_command() is None
39
+
40
+
41
+ def test_install_command_apt_adds_github_repo(monkeypatch):
42
+ """gh is not in the default Debian/Ubuntu repos, so the keyring must be set up."""
43
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
44
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which("apt-get"))
45
+ command = install_command()
46
+ assert command[:2] == ["bash", "-c"]
47
+ assert "githubcli-archive-keyring.gpg" in command[2]
48
+ assert "sudo apt install gh -y" in command[2]
49
+
50
+
51
+ def test_install_command_pacman(monkeypatch):
52
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
53
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which("pacman"))
54
+ assert install_command() == ["sudo", "pacman", "-S", "--noconfirm", "github-cli"]
55
+
56
+
57
+ def test_install_command_linux_without_package_manager(monkeypatch):
58
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
59
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which())
60
+ assert install_command() is None
61
+
62
+
63
+ def test_install_command_windows_winget_is_non_interactive(monkeypatch):
64
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
65
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which("winget"))
66
+ command = install_command()
67
+ assert command[:4] == ["winget", "install", "--id", "GitHub.cli"]
68
+ assert "--accept-package-agreements" in command
69
+
70
+
71
+ def test_install_command_windows_falls_back_to_scoop(monkeypatch):
72
+ monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
73
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which("scoop"))
74
+ assert install_command() == ["scoop", "install", "gh"]
75
+
76
+
77
+ def test_gh_version_none_when_missing(monkeypatch):
78
+ monkeypatch.setattr(gh_mod.shutil, "which", only_which())
79
+ assert gh_version() is None
80
+
81
+
82
+ def test_install_gh_skips_when_present(monkeypatch):
83
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
84
+ monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
85
+ assert install_gh() is True
86
+
87
+
88
+ def test_install_gh_reinstall_runs_the_installer(monkeypatch):
89
+ calls = []
90
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
91
+ monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
92
+ monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: calls.append(cmd))
93
+ assert install_gh(reinstall=True) is True
94
+ assert calls == [["brew", "install", "gh"]]
95
+
96
+
97
+ def test_install_gh_fails_without_package_manager(monkeypatch):
98
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
99
+ monkeypatch.setattr(gh_mod, "install_command", lambda: None)
100
+ monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
101
+ assert install_gh() is False
102
+
103
+
104
+ def test_install_gh_reports_failed_install(monkeypatch):
105
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
106
+ monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
107
+
108
+ def boom(cmd, **kwargs):
109
+ raise gh_mod.CommandError("brew exploded")
110
+
111
+ monkeypatch.setattr(gh_mod, "run_command", boom)
112
+ assert install_gh() is False
113
+
114
+
115
+ def test_install_gh_detects_binary_not_on_path(monkeypatch):
116
+ """A package manager can succeed while the binary is still not reachable."""
117
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
118
+ monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
119
+ monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: None)
120
+ assert install_gh() is False
121
+
122
+
123
+ class FakeResult:
124
+ def __init__(self, returncode):
125
+ self.returncode = returncode
126
+
127
+
128
+ def test_check_gh_auth_signed_in(monkeypatch):
129
+ monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(0))
130
+ assert check_gh_auth() is True
131
+
132
+
133
+ def test_check_gh_auth_signed_out(monkeypatch):
134
+ """`gh auth status` exits non-zero when logged out; that must not be fatal."""
135
+ monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(1))
136
+ assert check_gh_auth() is False
137
+
138
+
139
+ def test_setup_gh_skip_auth_check(runner, monkeypatch):
140
+ monkeypatch.setattr(gh_mod, "install_gh", lambda reinstall: True)
141
+ monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
142
+ monkeypatch.setattr(gh_mod, "check_gh_auth", lambda: pytest.fail("must not check auth"))
143
+
144
+ result = runner.invoke(cli, ["setup", "gh", "--skip-auth-check"])
145
+ assert result.exit_code == 0
146
+ assert "Auth check skipped" in result.output
@@ -1 +0,0 @@
1
- 0.10.3
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