Skip to content

Commit 88ee585

Browse files
Val Redchenkovredchenko
authored andcommitted
feat(smartem-workspace): add smart SSH/HTTPS auto-detection for repo cloning
- Auto-detect SSH authentication for GitHub repos (test via ssh -T [email protected]) - If SSH works, clone via SSH URLs (enables push); otherwise use HTTPS - Replace --ssh flag with --git-ssh (force SSH) and --git-https (force HTTPS) - Cache SSH check results to avoid repeated connection attempts - Notify user about clone method being used - GitLab repos continue to use HTTPS by default (read-only reference)
1 parent dcb85b4 commit 88ee585

File tree

5 files changed

+143
-15
lines changed

5 files changed

+143
-15
lines changed

packages/smartem-workspace/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,15 @@ smartem-workspace add DiamondLightSource/smartem-frontend
9797
--path PATH Target directory (default: current directory)
9898
--preset NAME Use preset: smartem-core, full, aria-reference, minimal
9999
--no-interactive Skip prompts, use preset only
100-
--ssh Use SSH URLs (default: HTTPS)
100+
--git-ssh Force SSH URLs for all repos
101+
--git-https Force HTTPS URLs (skip auto-detection)
101102
--with-claude Enable Claude Code integration setup
102103
--skip-serena Skip Serena MCP setup
103104
--skip-dev-requirements Skip developer requirements check
104105
```
105106

107+
**Git URL auto-detection:** By default, the CLI automatically detects if you have SSH authentication configured for GitHub. If SSH works, repos are cloned via SSH (enabling push); otherwise HTTPS is used (read-only). Use `--git-ssh` or `--git-https` to override.
108+
106109
### Check options
107110

108111
```

packages/smartem-workspace/smartem_workspace/cli.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,13 @@ def init(
8282
bool,
8383
typer.Option("--interactive/--no-interactive", help="Enable/disable interactive prompts"),
8484
] = True,
85-
ssh: Annotated[
85+
git_ssh: Annotated[
8686
bool,
87-
typer.Option("--ssh", help="Use SSH URLs instead of HTTPS"),
87+
typer.Option("--git-ssh", help="Force SSH URLs for all repos"),
88+
] = False,
89+
git_https: Annotated[
90+
bool,
91+
typer.Option("--git-https", help="Force HTTPS URLs (skip auto-detection)"),
8892
] = False,
8993
with_claude: Annotated[
9094
bool,
@@ -129,12 +133,22 @@ def init(
129133
out.print("[red]Failed to load Claude Code configuration[/red]")
130134
raise typer.Exit(1)
131135

136+
# Determine use_ssh: True=force SSH, False=force HTTPS, None=auto-detect
137+
use_ssh: bool | None = None
138+
if git_ssh and git_https:
139+
out.print("[red]Cannot specify both --git-ssh and --git-https[/red]")
140+
raise typer.Exit(1)
141+
elif git_ssh:
142+
use_ssh = True
143+
elif git_https:
144+
use_ssh = False
145+
132146
bootstrap_workspace(
133147
config=config,
134148
workspace_path=workspace_path,
135149
preset=preset,
136150
interactive=effective_interactive,
137-
use_ssh=ssh,
151+
use_ssh=use_ssh,
138152
skip_claude=skip_claude,
139153
skip_serena=skip_serena,
140154
claude_config=claude_config,

packages/smartem-workspace/smartem_workspace/setup/bootstrap.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def bootstrap_workspace(
4343
workspace_path: Path,
4444
preset: str | None = None,
4545
interactive: bool = True,
46-
use_ssh: bool = False,
46+
use_ssh: bool | None = None,
4747
skip_claude: bool = False,
4848
skip_serena: bool = False,
4949
claude_config: ClaudeCodeConfig | None = None,
@@ -56,7 +56,7 @@ def bootstrap_workspace(
5656
workspace_path: Target directory for workspace
5757
preset: Preset name (if provided, skips repo selection)
5858
interactive: Enable interactive prompts
59-
use_ssh: Use SSH URLs for cloning
59+
use_ssh: True=force SSH, False=force HTTPS, None=auto-detect for GitHub
6060
skip_claude: Skip Claude Code setup
6161
skip_serena: Skip Serena MCP setup
6262
claude_config: Claude Code configuration (required if skip_claude is False)

packages/smartem-workspace/smartem_workspace/setup/repos.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,35 @@
77
from rich.progress import Progress, SpinnerColumn, TextColumn
88

99
from smartem_workspace.config.schema import Organization, Repository
10+
from smartem_workspace.utils.git import check_github_ssh_auth
1011

1112
console = Console()
1213

1314

14-
def get_repo_url(repo: Repository, use_ssh: bool) -> str:
15-
"""Get the clone URL based on preference."""
16-
return repo.urls.ssh if use_ssh else repo.urls.https
15+
def get_repo_url(repo: Repository, org: Organization, use_ssh: bool | None, github_ssh_ok: bool | None = None) -> str:
16+
"""Get the clone URL based on preference or auto-detection.
17+
18+
Args:
19+
repo: Repository to get URL for
20+
org: Organization the repo belongs to
21+
use_ssh: True=force SSH, False=force HTTPS, None=auto-detect
22+
github_ssh_ok: Cached result of GitHub SSH check (for auto-detect)
23+
24+
Returns:
25+
Clone URL (SSH or HTTPS)
26+
"""
27+
# Explicit override
28+
if use_ssh is True:
29+
return repo.urls.ssh
30+
if use_ssh is False:
31+
return repo.urls.https
32+
33+
# Auto-detect mode
34+
if org.provider == "github" and github_ssh_ok:
35+
return repo.urls.ssh
36+
37+
# Default to HTTPS (GitLab or GitHub without SSH)
38+
return repo.urls.https
1739

1840

1941
def get_local_dir(org: Organization) -> str:
@@ -25,11 +47,19 @@ def clone_repo(
2547
repo: Repository,
2648
org: Organization,
2749
repos_dir: Path,
28-
use_ssh: bool = False,
50+
use_ssh: bool | None = None,
51+
github_ssh_ok: bool | None = None,
2952
) -> bool:
3053
"""
3154
Clone a single repository.
3255
56+
Args:
57+
repo: Repository to clone
58+
org: Organization the repo belongs to
59+
repos_dir: Directory to clone into
60+
use_ssh: True=force SSH, False=force HTTPS, None=auto-detect
61+
github_ssh_ok: Cached result of GitHub SSH check
62+
3363
Returns:
3464
True if successful or already exists, False on error
3565
"""
@@ -42,7 +72,7 @@ def clone_repo(
4272
console.print(f" [dim]Skipping {repo.name} (already exists)[/dim]")
4373
return True
4474

45-
url = get_repo_url(repo, use_ssh)
75+
url = get_repo_url(repo, org, use_ssh, github_ssh_ok)
4676

4777
try:
4878
result = subprocess.run(
@@ -67,7 +97,7 @@ def clone_repo(
6797
def clone_repos(
6898
repos: list[tuple[Organization, Repository]],
6999
workspace_path: Path,
70-
use_ssh: bool = False,
100+
use_ssh: bool | None = None,
71101
devtools_first: bool = True,
72102
) -> tuple[int, int]:
73103
"""
@@ -76,7 +106,7 @@ def clone_repos(
76106
Args:
77107
repos: List of (org, repo) tuples to clone
78108
workspace_path: Root workspace directory
79-
use_ssh: Use SSH URLs instead of HTTPS
109+
use_ssh: True=force SSH, False=force HTTPS, None=auto-detect for GitHub
80110
devtools_first: Clone smartem-devtools first (required for config)
81111
82112
Returns:
@@ -85,6 +115,26 @@ def clone_repos(
85115
repos_dir = workspace_path / "repos"
86116
repos_dir.mkdir(parents=True, exist_ok=True)
87117

118+
# Check SSH auth once at start for auto-detect mode
119+
github_ssh_ok: bool | None = None
120+
has_github_repos = any(org.provider == "github" for org, _ in repos)
121+
122+
if use_ssh is None and has_github_repos:
123+
console.print()
124+
console.print("[dim]Checking GitHub SSH authentication...[/dim]")
125+
github_ssh_ok = check_github_ssh_auth()
126+
if github_ssh_ok:
127+
console.print("[green]SSH authentication successful - using SSH for GitHub repos[/green]")
128+
else:
129+
console.print("[yellow]SSH not configured for GitHub - using HTTPS (anonymous/read-only)[/yellow]")
130+
console.print("[dim]To enable push access, configure SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh[/dim]")
131+
elif use_ssh is True:
132+
console.print()
133+
console.print("[dim]Using SSH URLs (--git-ssh)[/dim]")
134+
elif use_ssh is False:
135+
console.print()
136+
console.print("[dim]Using HTTPS URLs (--git-https)[/dim]")
137+
88138
success = 0
89139
failed = 0
90140

@@ -101,7 +151,7 @@ def clone_repos(
101151
console.print()
102152
console.print("[bold]Cloning smartem-devtools (required)...[/bold]")
103153
org, repo = devtools
104-
if clone_repo(repo, org, repos_dir, use_ssh):
154+
if clone_repo(repo, org, repos_dir, use_ssh, github_ssh_ok):
105155
success += 1
106156
else:
107157
failed += 1
@@ -122,7 +172,7 @@ def clone_repos(
122172

123173
for org, repo in repos:
124174
progress.update(task, description=f"Cloning {org.name}/{repo.name}...")
125-
if clone_repo(repo, org, repos_dir, use_ssh):
175+
if clone_repo(repo, org, repos_dir, use_ssh, github_ssh_ok):
126176
success += 1
127177
else:
128178
failed += 1

packages/smartem-workspace/smartem_workspace/utils/git.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,64 @@ def has_remote(repo_path: Path, remote: str = "origin") -> bool:
140140
if returncode == 0:
141141
return remote in stdout.split()
142142
return False
143+
144+
145+
_ssh_auth_cache: dict[str, bool] = {}
146+
147+
148+
def check_github_ssh_auth() -> bool:
149+
"""Test if SSH authentication to GitHub works.
150+
151+
GitHub's SSH test returns exit code 1 on successful authentication
152+
(with message "successfully authenticated"), and exit code 255 or
153+
timeout if SSH is not configured.
154+
155+
Results are cached to avoid repeated SSH connection attempts.
156+
"""
157+
if "github" in _ssh_auth_cache:
158+
return _ssh_auth_cache["github"]
159+
160+
try:
161+
result = subprocess.run(
162+
["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "[email protected]"],
163+
capture_output=True,
164+
text=True,
165+
timeout=10,
166+
)
167+
# GitHub returns exit code 1 on successful auth with specific message
168+
success = "successfully authenticated" in result.stderr.lower()
169+
_ssh_auth_cache["github"] = success
170+
return success
171+
except (subprocess.TimeoutExpired, FileNotFoundError):
172+
_ssh_auth_cache["github"] = False
173+
return False
174+
175+
176+
def check_gitlab_ssh_auth() -> bool:
177+
"""Test if SSH authentication to GitLab works.
178+
179+
Similar to GitHub, GitLab returns a welcome message on successful auth.
180+
Results are cached.
181+
"""
182+
if "gitlab" in _ssh_auth_cache:
183+
return _ssh_auth_cache["gitlab"]
184+
185+
try:
186+
result = subprocess.run(
187+
["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "[email protected]"],
188+
capture_output=True,
189+
text=True,
190+
timeout=10,
191+
)
192+
# GitLab returns exit code 0 on successful auth
193+
success = result.returncode == 0 or "welcome to gitlab" in result.stderr.lower()
194+
_ssh_auth_cache["gitlab"] = success
195+
return success
196+
except (subprocess.TimeoutExpired, FileNotFoundError):
197+
_ssh_auth_cache["gitlab"] = False
198+
return False
199+
200+
201+
def clear_ssh_auth_cache() -> None:
202+
"""Clear the SSH authentication cache (mainly for testing)."""
203+
_ssh_auth_cache.clear()

0 commit comments

Comments
 (0)