evo-cli 0.10.2__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.2 → 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.2 → 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.2 → evo_cli-0.11.0}/evo_cli/commands/opencode.py +62 -6
  7. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/mcp_registry.py +5 -5
  8. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/PKG-INFO +1 -1
  9. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/SOURCES.txt +4 -0
  10. evo_cli-0.11.0/tests/test_claude_code.py +228 -0
  11. evo_cli-0.11.0/tests/test_gh.py +146 -0
  12. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_opencode.py +26 -3
  13. evo_cli-0.10.2/evo_cli/VERSION +0 -1
  14. {evo_cli-0.10.2 → evo_cli-0.11.0}/Containerfile +0 -0
  15. {evo_cli-0.10.2 → evo_cli-0.11.0}/HISTORY.md +0 -0
  16. {evo_cli-0.10.2 → evo_cli-0.11.0}/LICENSE +0 -0
  17. {evo_cli-0.10.2 → evo_cli-0.11.0}/MANIFEST.in +0 -0
  18. {evo_cli-0.10.2 → evo_cli-0.11.0}/README.md +0 -0
  19. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/__init__.py +0 -0
  20. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/__main__.py +0 -0
  21. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/base.py +0 -0
  22. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/__init__.py +0 -0
  23. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/cloudflare.py +0 -0
  24. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/fix_claude.py +0 -0
  25. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/gdrive.py +0 -0
  26. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/localproxy.py +0 -0
  27. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/mcp.py +0 -0
  28. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/miniconda.py +0 -0
  29. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/netcheck.py +0 -0
  30. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/plantuml.py +0 -0
  31. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/site2s.py +0 -0
  32. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/ssh.py +0 -0
  33. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/sysmon.py +0 -0
  34. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/commands/wifi.py +0 -0
  35. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli/console.py +0 -0
  36. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  37. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/entry_points.txt +0 -0
  38. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/requires.txt +0 -0
  39. {evo_cli-0.10.2 → evo_cli-0.11.0}/evo_cli.egg-info/top_level.txt +0 -0
  40. {evo_cli-0.10.2 → evo_cli-0.11.0}/pyproject.toml +0 -0
  41. {evo_cli-0.10.2 → evo_cli-0.11.0}/setup.cfg +0 -0
  42. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/__init__.py +0 -0
  43. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_cli.py +0 -0
  44. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_console.py +0 -0
  45. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_fix_claude.py +0 -0
  46. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_mcp.py +0 -0
  47. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_plantuml.py +0 -0
  48. {evo_cli-0.10.2 → evo_cli-0.11.0}/tests/test_sysmon.py +0 -0
  49. {evo_cli-0.10.2 → 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.2
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)