diff --git a/.github/workflows/publish-smartem-workspace.yml b/.github/workflows/publish-smartem-workspace.yml index 2bf6586..50a031d 100644 --- a/.github/workflows/publish-smartem-workspace.yml +++ b/.github/workflows/publish-smartem-workspace.yml @@ -1,4 +1,4 @@ -name: Publish smartem-workspace to PyPI +name: smartem-workspace CLI tool PyPI package on: push: diff --git a/core/claude-code-config.json b/core/claude-code-config.json new file mode 100644 index 0000000..a8c22d6 --- /dev/null +++ b/core/claude-code-config.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "description": "Claude Code integration configuration for SmartEM workspace", + "claudeConfig": { + "skills": [ + { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, + { "name": "devops", "path": "claude-code/shared/skills/devops" }, + { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, + { "name": "git", "path": "claude-code/shared/skills/git" }, + { "name": "github", "path": "claude-code/shared/skills/github" }, + { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, + { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } + ], + "defaultPermissions": { + "allow": [ + "Bash(git:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebSearch", + "mcp__serena__*" + ] + } + }, + "serenaConfig": { + "languages": ["typescript", "python"], + "encoding": "utf-8", + "ignoreAllFilesInGitignore": true, + "projectName": "smartem-workspace" + }, + "mcpConfig": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "${PWD}" + ] + } + } +} diff --git a/core/claude-code-config.ts b/core/claude-code-config.ts new file mode 100644 index 0000000..e274d10 --- /dev/null +++ b/core/claude-code-config.ts @@ -0,0 +1,50 @@ +/** + * Claude Code integration configuration for SmartEM workspace. + * + * Source of truth: claude-code-config.json + * This file re-exports the JSON data with TypeScript types for type safety. + */ + +import claudeCodeConfig from './claude-code-config.json' + +export interface SkillDefinition { + name: string + path: string +} + +export interface DefaultPermissions { + allow: string[] +} + +export interface ClaudeConfig { + skills: SkillDefinition[] + defaultPermissions: DefaultPermissions +} + +export interface SerenaConfig { + languages: string[] + encoding: string + ignoreAllFilesInGitignore: boolean + projectName: string +} + +export interface McpServerConfig { + command: string + args: string[] +} + +export interface McpConfig { + serena: McpServerConfig +} + +export interface ClaudeCodeConfigFile { + version: string + description: string + claudeConfig: ClaudeConfig + serenaConfig: SerenaConfig + mcpConfig: McpConfig +} + +export const claudeCodeConfigData: ClaudeCodeConfigFile = claudeCodeConfig + +export default claudeCodeConfigData diff --git a/core/dev-requirements.json b/core/dev-requirements.json new file mode 100644 index 0000000..2ed4105 --- /dev/null +++ b/core/dev-requirements.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "description": "Developer requirements for SmartEM workspace local environment setup", + "tools": [ + { + "name": "git", + "command": "git", + "versionArgs": ["--version"], + "required": true, + "description": "Version control system" + }, + { + "name": "Python", + "command": "python3", + "versionArgs": ["--version"], + "required": true, + "minVersion": "3.11", + "description": "Python interpreter (3.11+ required)" + }, + { + "name": "uv", + "command": "uv", + "versionArgs": ["--version"], + "required": false, + "description": "Fast Python package installer (recommended)" + }, + { + "name": "Node.js", + "command": "node", + "versionArgs": ["--version"], + "required": false, + "minVersion": "22", + "description": "JavaScript runtime for frontend development" + }, + { + "name": "npm", + "command": "npm", + "versionArgs": ["--version"], + "required": false, + "description": "Node package manager" + }, + { + "name": "Container runtime", + "command": "docker", + "versionArgs": ["--version"], + "required": false, + "alternatives": ["podman"], + "description": "Container runtime (docker or podman)" + }, + { + "name": "kubectl", + "command": "kubectl", + "versionArgs": ["version", "--client", "--output=yaml"], + "required": false, + "description": "Kubernetes CLI" + }, + { + "name": "k3s", + "command": "k3s", + "versionArgs": ["--version"], + "required": false, + "description": "Lightweight Kubernetes distribution" + }, + { + "name": "gh", + "command": "gh", + "versionArgs": ["--version"], + "required": false, + "description": "GitHub CLI" + } + ], + "network": { + "checkUrl": "https://github.com", + "timeout": 5, + "required": false, + "description": "Network connectivity to GitHub" + } +} diff --git a/core/dev-requirements.ts b/core/dev-requirements.ts new file mode 100644 index 0000000..67c61d5 --- /dev/null +++ b/core/dev-requirements.ts @@ -0,0 +1,36 @@ +/** + * Developer requirements for SmartEM workspace local environment setup. + * + * Source of truth: dev-requirements.json + * This file re-exports the JSON data with TypeScript types for type safety. + */ + +import devRequirementsConfig from './dev-requirements.json' + +export interface ToolRequirement { + name: string + command: string + versionArgs: string[] + required: boolean + minVersion?: string + alternatives?: string[] + description: string +} + +export interface NetworkCheck { + checkUrl: string + timeout: number + required: boolean + description: string +} + +export interface DevRequirementsConfig { + version: string + description: string + tools: ToolRequirement[] + network: NetworkCheck +} + +export const devRequirements: DevRequirementsConfig = devRequirementsConfig + +export default devRequirements diff --git a/core/repos.json b/core/repos.json index 1fa7dd5..33ce5f5 100644 --- a/core/repos.json +++ b/core/repos.json @@ -358,46 +358,5 @@ } ] } - ], - "claudeConfig": { - "skills": [ - { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, - { "name": "devops", "path": "claude-code/shared/skills/devops" }, - { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, - { "name": "git", "path": "claude-code/shared/skills/git" }, - { "name": "github", "path": "claude-code/shared/skills/github" }, - { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, - { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } - ], - "defaultPermissions": { - "allow": [ - "Bash(git:*)", - "Bash(ls:*)", - "Bash(cat:*)", - "WebSearch", - "mcp__serena__*" - ] - } - }, - "serenaConfig": { - "languages": ["typescript", "python"], - "encoding": "utf-8", - "ignoreAllFilesInGitignore": true, - "projectName": "smartem-workspace" - }, - "mcpConfig": { - "serena": { - "command": "uvx", - "args": [ - "--from", - "git+https://github.com/oraios/serena", - "serena", - "start-mcp-server", - "--context", - "ide-assistant", - "--project", - "${PWD}" - ] - } - } + ] } diff --git a/packages/smartem-workspace/README.md b/packages/smartem-workspace/README.md index 0073f95..5a2370b 100644 --- a/packages/smartem-workspace/README.md +++ b/packages/smartem-workspace/README.md @@ -19,6 +19,13 @@ uv tool install smartem-workspace ## Usage +### Global options + +``` +--no-color Disable colored output +--plain Plain mode: no color, no interactive prompts +``` + ### Initialize a new workspace ```bash @@ -77,6 +84,9 @@ Sync skips repos with uncommitted changes or not on main/master branch. # Show workspace status (alias for check) smartem-workspace status +# Set up Claude Code integration (after init without --with-claude) +smartem-workspace claude setup + # Add a single repo (not yet implemented) smartem-workspace add DiamondLightSource/smartem-frontend ``` @@ -87,15 +97,19 @@ smartem-workspace add DiamondLightSource/smartem-frontend --path PATH Target directory (default: current directory) --preset NAME Use preset: smartem-core, full, aria-reference, minimal --no-interactive Skip prompts, use preset only ---ssh Use SSH URLs (default: HTTPS) ---skip-claude Skip Claude Code setup +--git-ssh Force SSH URLs for all repos +--git-https Force HTTPS URLs (skip auto-detection) +--with-claude Enable Claude Code integration setup --skip-serena Skip Serena MCP setup +--skip-dev-requirements Skip developer requirements check ``` +**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. + ### Check options ``` ---scope SCOPE Check scope: claude, repos, serena, or all (default: all) +--scope SCOPE Check scope: dev-requirements, claude, repos, serena, or all (default: all) --fix Attempt to fix issues (recreate symlinks, dirs) --offline Use bundled config instead of fetching from GitHub ``` @@ -109,9 +123,9 @@ smartem-workspace add DiamondLightSource/smartem-frontend ## What it sets up 1. **Repository clones** - Organized by organization (DiamondLightSource, FragmentScreen, GitlabAriaPHP) -2. **Claude Code configuration** - Skills, settings, permissions +2. **Claude Code configuration** (with `--with-claude`) - Skills, settings, permissions 3. **Serena MCP server** - Semantic code navigation -4. **Workspace structure** - CLAUDE.md, tmp/, testdata/ directories +4. **Workspace structure** - tmp/, testdata/ directories ## Documentation diff --git a/packages/smartem-workspace/smartem_workspace/cli.py b/packages/smartem-workspace/smartem_workspace/cli.py index 44bb75d..0369586 100644 --- a/packages/smartem-workspace/smartem_workspace/cli.py +++ b/packages/smartem-workspace/smartem_workspace/cli.py @@ -11,9 +11,11 @@ apply_fixes, print_report, run_checks, + run_dev_requirements_checks, ) +from smartem_workspace.commands.claude import setup as claude_setup_fn from smartem_workspace.commands.sync import print_sync_results, sync_all_repos -from smartem_workspace.config.loader import load_config +from smartem_workspace.config.loader import load_claude_code_config, load_config from smartem_workspace.setup.bootstrap import bootstrap_workspace from smartem_workspace.utils.paths import find_workspace_root @@ -22,9 +24,50 @@ help="CLI tool to automate SmartEM multi-repo workspace setup", no_args_is_help=True, ) + +claude_app = typer.Typer( + name="claude", + help="Claude Code integration commands", + no_args_is_help=True, +) +app.add_typer(claude_app, name="claude") + console = Console() +class CliState: + """Global CLI state for color and interactivity settings.""" + + no_color: bool = False + plain: bool = False + + +cli_state = CliState() + + +def get_console() -> Console: + """Get a console instance respecting global color settings.""" + if cli_state.no_color or cli_state.plain: + return Console(color_system=None) + return console + + +@app.callback() +def main( + no_color: Annotated[ + bool, + typer.Option("--no-color", help="Disable colored output"), + ] = False, + plain: Annotated[ + bool, + typer.Option("--plain", help="Plain mode: no color, no interactive prompts"), + ] = False, +) -> None: + """SmartEM workspace CLI tool.""" + cli_state.no_color = no_color + cli_state.plain = plain + + @app.command() def init( path: Annotated[ @@ -39,38 +82,76 @@ def init( bool, typer.Option("--interactive/--no-interactive", help="Enable/disable interactive prompts"), ] = True, - ssh: Annotated[ + git_ssh: Annotated[ + bool, + typer.Option("--git-ssh", help="Force SSH URLs for all repos"), + ] = False, + git_https: Annotated[ bool, - typer.Option("--ssh", help="Use SSH URLs instead of HTTPS"), + typer.Option("--git-https", help="Force HTTPS URLs (skip auto-detection)"), ] = False, - skip_claude: Annotated[ + with_claude: Annotated[ bool, - typer.Option("--skip-claude", help="Skip Claude Code setup"), + typer.Option("--with-claude", help="Enable Claude Code integration setup"), ] = False, skip_serena: Annotated[ bool, typer.Option("--skip-serena", help="Skip Serena MCP setup"), ] = False, + skip_dev_requirements: Annotated[ + bool, + typer.Option("--skip-dev-requirements", help="Skip developer requirements check"), + ] = False, ) -> None: """Initialize a new SmartEM workspace.""" - workspace_path = path or Path.cwd() + out = get_console() + out.print("[bold blue]SmartEM Workspace Setup[/bold blue]") + + effective_interactive = interactive and not cli_state.plain - console.print("[bold blue]SmartEM Workspace Setup[/bold blue]") - console.print(f"Target: {workspace_path.absolute()}") + if not skip_dev_requirements: + dev_reqs_report = run_dev_requirements_checks() + print_report(dev_reqs_report) + if dev_reqs_report.has_errors: + out.print("\n[red]Developer requirements check failed. Fix the errors above before continuing.[/red]") + raise typer.Exit(1) + out.print() + + workspace_path = path or Path.cwd() + out.print(f"Target: {workspace_path.absolute()}") config = load_config() if config is None: - console.print("[red]Failed to load configuration[/red]") + out.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + skip_claude = not with_claude + claude_config = None + if with_claude: + claude_config = load_claude_code_config() + if claude_config is None: + out.print("[red]Failed to load Claude Code configuration[/red]") + raise typer.Exit(1) + + # Determine use_ssh: True=force SSH, False=force HTTPS, None=auto-detect + use_ssh: bool | None = None + if git_ssh and git_https: + out.print("[red]Cannot specify both --git-ssh and --git-https[/red]") raise typer.Exit(1) + elif git_ssh: + use_ssh = True + elif git_https: + use_ssh = False bootstrap_workspace( config=config, workspace_path=workspace_path, preset=preset, - interactive=interactive, - use_ssh=ssh, + interactive=effective_interactive, + use_ssh=use_ssh, skip_claude=skip_claude, skip_serena=skip_serena, + claude_config=claude_config, ) @@ -78,7 +159,7 @@ def init( def check( scope: Annotated[ str | None, - typer.Option("--scope", "-s", help="Check scope: claude, repos, serena, or all"), + typer.Option("--scope", "-s", help="Check scope: dev-requirements, claude, repos, serena, or all"), ] = None, fix: Annotated[ bool, @@ -94,26 +175,35 @@ def check( ] = False, ) -> None: """Verify workspace setup and optionally repair issues.""" - workspace_path = path or find_workspace_root() - if workspace_path is None: - console.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") - raise typer.Exit(1) - - config = load_config(offline=offline) - if config is None: - console.print("[red]Failed to load configuration[/red]") - raise typer.Exit(1) - + out = get_console() check_scope = CheckScope.ALL if scope: try: check_scope = CheckScope(scope.lower()) except ValueError: - console.print(f"[red]Invalid scope: {scope}. Use: claude, repos, serena, or all[/red]") + out.print(f"[red]Invalid scope: {scope}. Use: dev-requirements, claude, repos, serena, or all[/red]") raise typer.Exit(1) from None - console.print(f"[bold]Checking workspace at {workspace_path}...[/bold]") - reports = run_checks(workspace_path, config, check_scope) + if check_scope == CheckScope.DEV_REQUIREMENTS: + out.print("[bold]Checking developer requirements...[/bold]") + reports = run_checks(None, None, check_scope) + else: + workspace_path = path or find_workspace_root() + if workspace_path is None: + out.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") + raise typer.Exit(1) + + config = load_config(offline=offline) + if config is None: + out.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + claude_config = None + if check_scope in (CheckScope.ALL, CheckScope.CLAUDE): + claude_config = load_claude_code_config(offline=offline) + + out.print(f"[bold]Checking workspace at {workspace_path}...[/bold]") + reports = run_checks(workspace_path, config, check_scope, claude_config) for report in reports: print_report(report) @@ -122,28 +212,28 @@ def check( total_warnings = sum(r.has_warnings for r in reports) total_fixable = sum(r.fixable_count for r in reports) - console.print() + out.print() if total_errors or total_warnings: parts = [] if total_errors: parts.append(f"[red]{total_errors} error(s)[/red]") if total_warnings: parts.append(f"[yellow]{total_warnings} warning(s)[/yellow]") - console.print(f"Summary: {', '.join(parts)}") + out.print(f"Summary: {', '.join(parts)}") - if fix and total_fixable: - console.print("\n[bold]Applying fixes...[/bold]") + if fix and total_fixable and check_scope != CheckScope.DEV_REQUIREMENTS: + out.print("\n[bold]Applying fixes...[/bold]") fixed, failed = apply_fixes(workspace_path, reports) - console.print(f"\nFixed {fixed} issue(s), {failed} failed") + out.print(f"\nFixed {fixed} issue(s), {failed} failed") if failed: raise typer.Exit(1) - elif total_fixable and not fix: - console.print(f"\n[dim]{total_fixable} issue(s) can be fixed with --fix[/dim]") + elif total_fixable and not fix and check_scope != CheckScope.DEV_REQUIREMENTS: + out.print(f"\n[dim]{total_fixable} issue(s) can be fixed with --fix[/dim]") raise typer.Exit(1) - else: + elif total_errors: raise typer.Exit(1) else: - console.print("[green]All checks passed![/green]") + out.print("[green]All checks passed![/green]") @app.command() @@ -158,18 +248,19 @@ def sync( ] = None, ) -> None: """Pull latest changes from all cloned repositories.""" + out = get_console() workspace_path = path or find_workspace_root() if workspace_path is None: - console.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") + out.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") raise typer.Exit(1) config = load_config() if config is None: - console.print("[red]Failed to load configuration[/red]") + out.print("[red]Failed to load configuration[/red]") raise typer.Exit(1) - console.print("[bold blue]SmartEM Workspace Sync[/bold blue]") - console.print(f"Workspace: {workspace_path}") + out.print("[bold blue]SmartEM Workspace Sync[/bold blue]") + out.print(f"Workspace: {workspace_path}") results = sync_all_repos(workspace_path, config, dry_run=dry_run) print_sync_results(results) @@ -181,7 +272,7 @@ def sync( if dry_run: would_update = sum(1 for r in results if r.status == "dry-run") if would_update: - console.print("\n[dim]Run without --dry-run to apply changes[/dim]") + out.print("\n[dim]Run without --dry-run to apply changes[/dim]") @app.command() @@ -190,20 +281,27 @@ def status( Path | None, typer.Option("--path", "-p", help="Workspace path"), ] = None, + offline: Annotated[ + bool, + typer.Option("--offline", help="Use bundled config instead of fetching from GitHub"), + ] = False, ) -> None: """Show workspace status (alias for check --scope all).""" + out = get_console() workspace_path = path or find_workspace_root() if workspace_path is None: - console.print("[red]Could not find workspace root.[/red]") + out.print("[red]Could not find workspace root.[/red]") raise typer.Exit(1) - config = load_config() + config = load_config(offline=offline) if config is None: - console.print("[red]Failed to load configuration[/red]") + out.print("[red]Failed to load configuration[/red]") raise typer.Exit(1) - console.print(f"[bold]Workspace Status: {workspace_path}[/bold]") - reports = run_checks(workspace_path, config, CheckScope.ALL) + claude_config = load_claude_code_config(offline=offline) + + out.print(f"[bold]Workspace Status: {workspace_path}[/bold]") + reports = run_checks(workspace_path, config, CheckScope.ALL, claude_config) for report in reports: print_report(report) @@ -214,9 +312,25 @@ def add( repo: Annotated[str, typer.Argument(help="Repository to add (e.g., DiamondLightSource/smartem-frontend)")], ) -> None: """Add a single repository to the workspace.""" - console.print(f"[yellow]Not implemented yet: {repo}[/yellow]") + out = get_console() + out.print(f"[yellow]Not implemented yet: {repo}[/yellow]") raise typer.Exit(1) +@claude_app.command("setup") +def claude_setup( + path: Annotated[ + Path | None, + typer.Option("--path", "-p", help="Workspace path (auto-detected if not specified)"), + ] = None, + offline: Annotated[ + bool, + typer.Option("--offline", help="Use bundled config instead of fetching from GitHub"), + ] = False, +) -> None: + """Set up Claude Code integration in the workspace.""" + claude_setup_fn(path=path, offline=offline) + + if __name__ == "__main__": app() diff --git a/packages/smartem-workspace/smartem_workspace/commands/__init__.py b/packages/smartem-workspace/smartem_workspace/commands/__init__.py index 6669d99..551f06a 100644 --- a/packages/smartem-workspace/smartem_workspace/commands/__init__.py +++ b/packages/smartem-workspace/smartem_workspace/commands/__init__.py @@ -1,6 +1,12 @@ """Command implementations for smartem-workspace CLI.""" -from smartem_workspace.commands.check import CheckReport, CheckResult, CheckScope, run_checks +from smartem_workspace.commands.check import ( + CheckReport, + CheckResult, + CheckScope, + run_checks, + run_dev_requirements_checks, +) from smartem_workspace.commands.sync import SyncResult, sync_all_repos __all__ = [ @@ -9,5 +15,6 @@ "CheckScope", "SyncResult", "run_checks", + "run_dev_requirements_checks", "sync_all_repos", ] diff --git a/packages/smartem-workspace/smartem_workspace/commands/check.py b/packages/smartem-workspace/smartem_workspace/commands/check.py index 5a15350..733f07b 100644 --- a/packages/smartem-workspace/smartem_workspace/commands/check.py +++ b/packages/smartem-workspace/smartem_workspace/commands/check.py @@ -2,24 +2,34 @@ import json import os +import re +import shutil +import subprocess from dataclasses import dataclass, field from enum import Enum +from importlib import resources from pathlib import Path from typing import Literal +import httpx from rich.console import Console -from smartem_workspace.config.schema import ReposConfig +from smartem_workspace.config.schema import ClaudeCodeConfig, ReposConfig from smartem_workspace.setup.repos import get_local_dir console = Console() +DEV_REQUIREMENTS_URL = ( + "https://raw.githubusercontent.com/DiamondLightSource/smartem-devtools/main/core/dev-requirements.json" +) + class CheckScope(str, Enum): ALL = "all" CLAUDE = "claude" REPOS = "repos" SERENA = "serena" + DEV_REQUIREMENTS = "dev-requirements" @dataclass @@ -49,6 +59,136 @@ def fixable_count(self) -> int: return sum(1 for r in self.results if r.fixable) +def _load_dev_requirements() -> dict | None: + """Load dev-requirements.json from network or bundled fallback.""" + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(DEV_REQUIREMENTS_URL) + response.raise_for_status() + return response.json() + except (httpx.HTTPError, json.JSONDecodeError): + pass + + try: + config_path = resources.files("smartem_workspace.config").joinpath("dev-requirements.json") + with resources.as_file(config_path) as path: + if path.exists(): + return json.loads(path.read_text()) + except Exception: + pass + + return None + + +def _parse_version(version_str: str) -> tuple[int, ...] | None: + """Extract version numbers from a version string.""" + match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_str) + if match: + parts = [int(p) for p in match.groups() if p is not None] + return tuple(parts) + return None + + +def _compare_versions(actual: str, min_version: str) -> bool: + """Check if actual version meets minimum requirement.""" + actual_parts = _parse_version(actual) + min_parts = _parse_version(min_version) + + if not actual_parts or not min_parts: + return True + + for a, m in zip(actual_parts, min_parts, strict=False): + if a > m: + return True + if a < m: + return False + return len(actual_parts) >= len(min_parts) + + +def check_tool(tool: dict) -> CheckResult: + """Check a tool prerequisite from JSON definition.""" + name = tool["name"] + command = tool["command"] + version_args = tool.get("versionArgs", ["--version"]) + required = tool.get("required", False) + min_version = tool.get("minVersion") + alternatives = tool.get("alternatives", []) + + commands_to_try = [command] + alternatives + found_command = None + version_output = None + + for cmd in commands_to_try: + cmd_path = shutil.which(cmd) + if cmd_path: + found_command = cmd + try: + result = subprocess.run( + [cmd] + version_args, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + version_output = result.stdout.strip() or result.stderr.strip() + break + except (subprocess.TimeoutExpired, FileNotFoundError): + continue + + if not found_command: + status = "error" if required else "warning" + return CheckResult(name, status, "Not found in PATH") + + if version_output: + display_version = version_output.split("\n")[0][:60] + + if min_version: + if _compare_versions(version_output, min_version): + return CheckResult(name, "ok", display_version) + status = "error" if required else "warning" + return CheckResult(name, status, f"{display_version} (requires {min_version}+)") + + return CheckResult(name, "ok", display_version) + + return CheckResult(name, "ok", f"Found: {found_command}") + + +def check_network(network_config: dict) -> CheckResult: + """Check network connectivity based on JSON definition.""" + url = network_config.get("checkUrl", "https://github.com") + timeout = network_config.get("timeout", 5) + required = network_config.get("required", False) + + try: + with httpx.Client(timeout=float(timeout)) as client: + response = client.head(url) + if response.status_code < 400: + return CheckResult("Network", "ok", f"{url} reachable") + status = "error" if required else "warning" + return CheckResult("Network", status, f"{url} returned {response.status_code}") + except httpx.RequestError: + status = "error" if required else "warning" + return CheckResult("Network", status, f"Cannot reach {url}") + + +def run_dev_requirements_checks() -> CheckReport: + """Run all developer requirements checks from dev-requirements.json.""" + config = _load_dev_requirements() + results = [] + + if config is None: + results.append(CheckResult("dev-requirements.json", "error", "Failed to load configuration")) + return CheckReport("dev-requirements", results) + + for tool in config.get("tools", []): + results.append(check_tool(tool)) + + if "network" in config: + results.append(check_network(config["network"])) + + return CheckReport("dev-requirements", results) + + def check_devtools_present(workspace_path: Path) -> CheckResult: devtools_path = workspace_path / "repos" / "DiamondLightSource" / "smartem-devtools" if devtools_path.exists() and (devtools_path / ".git").exists(): @@ -107,7 +247,8 @@ def check_json_valid(file_path: Path, name: str) -> CheckResult: return CheckResult(name, "error", f"Invalid JSON: {e}") -def run_claude_checks(workspace_path: Path, config: ReposConfig) -> CheckReport: +def run_claude_checks(workspace_path: Path, claude_config: ClaudeCodeConfig) -> CheckReport: + """Check Claude Code integration setup.""" results = [] devtools_path = workspace_path / "repos" / "DiamondLightSource" / "smartem-devtools" @@ -135,7 +276,7 @@ def run_claude_checks(workspace_path: Path, config: ReposConfig) -> CheckReport: else: results.append(CheckResult(".claude/skills directory", "ok", "Present")) - for skill in config.claudeConfig.skills: + for skill in claude_config.claudeConfig.skills: skill_link = skills_dir / skill.name skill_target = devtools_path / skill.path results.append(check_symlink(skill_link, skill_target, f"skill: {skill.name}")) @@ -202,19 +343,23 @@ def run_repos_checks(workspace_path: Path, config: ReposConfig) -> CheckReport: def run_checks( - workspace_path: Path, - config: ReposConfig, + workspace_path: Path | None, + config: ReposConfig | None, scope: CheckScope = CheckScope.ALL, + claude_config: ClaudeCodeConfig | None = None, ) -> list[CheckReport]: reports = [] - if scope in (CheckScope.ALL, CheckScope.CLAUDE): - reports.append(run_claude_checks(workspace_path, config)) + if scope in (CheckScope.ALL, CheckScope.DEV_REQUIREMENTS): + reports.append(run_dev_requirements_checks()) + + if scope in (CheckScope.ALL, CheckScope.CLAUDE) and workspace_path and claude_config: + reports.append(run_claude_checks(workspace_path, claude_config)) - if scope in (CheckScope.ALL, CheckScope.SERENA): + if scope in (CheckScope.ALL, CheckScope.SERENA) and workspace_path: reports.append(run_serena_checks(workspace_path)) - if scope in (CheckScope.ALL, CheckScope.REPOS): + if scope in (CheckScope.ALL, CheckScope.REPOS) and workspace_path and config: reports.append(run_repos_checks(workspace_path, config)) return reports @@ -265,7 +410,8 @@ def apply_fixes(workspace_path: Path, reports: list[CheckReport]) -> tuple[int, def print_report(report: CheckReport) -> None: - console.print(f"\n[bold]{report.scope.title()} Configuration:[/bold]") + title = report.scope.replace("-", " ").title() + console.print(f"\n[bold]{title}:[/bold]") for result in report.results: if result.status == "ok": diff --git a/packages/smartem-workspace/smartem_workspace/commands/claude.py b/packages/smartem-workspace/smartem_workspace/commands/claude.py new file mode 100644 index 0000000..34a738c --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/commands/claude.py @@ -0,0 +1,42 @@ +"""Claude Code setup command.""" + +from pathlib import Path + +import typer +from rich.console import Console + +from smartem_workspace.config.loader import load_claude_code_config +from smartem_workspace.setup.claude import setup_claude_config +from smartem_workspace.utils.paths import find_workspace_root + +console = Console() + + +def setup( + path: Path | None = None, + offline: bool = False, +) -> bool: + """ + Set up Claude Code integration in the workspace. + + Args: + path: Workspace path (auto-detected if not specified) + offline: Use bundled config instead of fetching from GitHub + + Returns: + True if successful + """ + workspace_path = path or find_workspace_root() + if workspace_path is None: + console.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") + raise typer.Exit(1) + + claude_config = load_claude_code_config(offline=offline) + if claude_config is None: + console.print("[red]Failed to load Claude Code configuration[/red]") + raise typer.Exit(1) + + console.print("[bold blue]Claude Code Setup[/bold blue]") + console.print(f"Workspace: {workspace_path}") + + return setup_claude_config(claude_config, workspace_path) diff --git a/packages/smartem-workspace/smartem_workspace/config/claude-code-config.json b/packages/smartem-workspace/smartem_workspace/config/claude-code-config.json new file mode 100644 index 0000000..a8c22d6 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/claude-code-config.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "description": "Claude Code integration configuration for SmartEM workspace", + "claudeConfig": { + "skills": [ + { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, + { "name": "devops", "path": "claude-code/shared/skills/devops" }, + { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, + { "name": "git", "path": "claude-code/shared/skills/git" }, + { "name": "github", "path": "claude-code/shared/skills/github" }, + { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, + { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } + ], + "defaultPermissions": { + "allow": [ + "Bash(git:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebSearch", + "mcp__serena__*" + ] + } + }, + "serenaConfig": { + "languages": ["typescript", "python"], + "encoding": "utf-8", + "ignoreAllFilesInGitignore": true, + "projectName": "smartem-workspace" + }, + "mcpConfig": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "${PWD}" + ] + } + } +} diff --git a/packages/smartem-workspace/smartem_workspace/config/dev-requirements.json b/packages/smartem-workspace/smartem_workspace/config/dev-requirements.json new file mode 100644 index 0000000..2ed4105 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/dev-requirements.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "description": "Developer requirements for SmartEM workspace local environment setup", + "tools": [ + { + "name": "git", + "command": "git", + "versionArgs": ["--version"], + "required": true, + "description": "Version control system" + }, + { + "name": "Python", + "command": "python3", + "versionArgs": ["--version"], + "required": true, + "minVersion": "3.11", + "description": "Python interpreter (3.11+ required)" + }, + { + "name": "uv", + "command": "uv", + "versionArgs": ["--version"], + "required": false, + "description": "Fast Python package installer (recommended)" + }, + { + "name": "Node.js", + "command": "node", + "versionArgs": ["--version"], + "required": false, + "minVersion": "22", + "description": "JavaScript runtime for frontend development" + }, + { + "name": "npm", + "command": "npm", + "versionArgs": ["--version"], + "required": false, + "description": "Node package manager" + }, + { + "name": "Container runtime", + "command": "docker", + "versionArgs": ["--version"], + "required": false, + "alternatives": ["podman"], + "description": "Container runtime (docker or podman)" + }, + { + "name": "kubectl", + "command": "kubectl", + "versionArgs": ["version", "--client", "--output=yaml"], + "required": false, + "description": "Kubernetes CLI" + }, + { + "name": "k3s", + "command": "k3s", + "versionArgs": ["--version"], + "required": false, + "description": "Lightweight Kubernetes distribution" + }, + { + "name": "gh", + "command": "gh", + "versionArgs": ["--version"], + "required": false, + "description": "GitHub CLI" + } + ], + "network": { + "checkUrl": "https://github.com", + "timeout": 5, + "required": false, + "description": "Network connectivity to GitHub" + } +} diff --git a/packages/smartem-workspace/smartem_workspace/config/loader.py b/packages/smartem-workspace/smartem_workspace/config/loader.py index 9364247..1636d89 100644 --- a/packages/smartem-workspace/smartem_workspace/config/loader.py +++ b/packages/smartem-workspace/smartem_workspace/config/loader.py @@ -2,24 +2,25 @@ import json from importlib import resources -from pathlib import Path import httpx from rich.console import Console -from smartem_workspace.config.schema import ReposConfig +from smartem_workspace.config.schema import ClaudeCodeConfig, ReposConfig -GITHUB_RAW_URL = "https://raw.githubusercontent.com/DiamondLightSource/smartem-devtools/main/core/repos.json" +GITHUB_RAW_BASE = "https://raw.githubusercontent.com/DiamondLightSource/smartem-devtools/main/core" +REPOS_CONFIG_URL = f"{GITHUB_RAW_BASE}/repos.json" +CLAUDE_CODE_CONFIG_URL = f"{GITHUB_RAW_BASE}/claude-code-config.json" REQUEST_TIMEOUT = 10.0 console = Console() -def load_from_network() -> dict | None: - """Attempt to load config from GitHub.""" +def _load_json_from_network(url: str) -> dict | None: + """Attempt to load JSON config from a URL.""" try: with httpx.Client(timeout=REQUEST_TIMEOUT) as client: - response = client.get(GITHUB_RAW_URL) + response = client.get(url) response.raise_for_status() return response.json() except httpx.HTTPError as e: @@ -30,10 +31,10 @@ def load_from_network() -> dict | None: return None -def load_from_bundled() -> dict | None: - """Load bundled fallback config.""" +def _load_json_from_bundled(filename: str) -> dict | None: + """Load bundled fallback config by filename.""" try: - config_path = resources.files("smartem_workspace.config").joinpath("repos.json") + config_path = resources.files("smartem_workspace.config").joinpath(filename) with resources.as_file(config_path) as path: if path.exists(): return json.loads(path.read_text()) @@ -43,27 +44,16 @@ def load_from_bundled() -> dict | None: return None -def load_from_file(path: Path) -> dict | None: - """Load config from a local file path.""" - try: - return json.loads(path.read_text()) - except Exception as e: - console.print(f"[dim]File load failed: {e}[/dim]") - return None - - -def load_config(local_path: Path | None = None, offline: bool = False) -> ReposConfig | None: +def load_config(offline: bool = False) -> ReposConfig | None: """ - Load workspace configuration. + Load workspace repository configuration. Strategy: - 1. If local_path provided, use that - 2. If offline, use bundled config - 3. Try network (GitHub raw) - 4. Fall back to bundled config + 1. If offline, use bundled config + 2. Try network (GitHub raw) + 3. Fall back to bundled config Args: - local_path: Path to local config file offline: Skip network fetch, use bundled config Returns: @@ -71,19 +61,16 @@ def load_config(local_path: Path | None = None, offline: bool = False) -> ReposC """ config_dict: dict | None = None - if local_path: - console.print(f"[dim]Loading config from: {local_path}[/dim]") - config_dict = load_from_file(local_path) - elif offline: + if offline: console.print("[dim]Using bundled config (offline mode)[/dim]") - config_dict = load_from_bundled() + config_dict = _load_json_from_bundled("repos.json") else: console.print("[dim]Fetching latest config from GitHub...[/dim]") - config_dict = load_from_network() + config_dict = _load_json_from_network(REPOS_CONFIG_URL) if config_dict is None: console.print("[dim]Using bundled fallback config[/dim]") - config_dict = load_from_bundled() + config_dict = _load_json_from_bundled("repos.json") if config_dict is None: console.print("[red]Failed to load configuration from any source[/red]") @@ -94,3 +81,42 @@ def load_config(local_path: Path | None = None, offline: bool = False) -> ReposC except Exception as e: console.print(f"[red]Configuration validation failed: {e}[/red]") return None + + +def load_claude_code_config(offline: bool = False) -> ClaudeCodeConfig | None: + """ + Load Claude Code integration configuration. + + Strategy: + 1. If offline, use bundled config + 2. Try network (GitHub raw) + 3. Fall back to bundled config + + Args: + offline: Skip network fetch, use bundled config + + Returns: + ClaudeCodeConfig if successful, None otherwise + """ + config_dict: dict | None = None + + if offline: + console.print("[dim]Using bundled Claude Code config (offline mode)[/dim]") + config_dict = _load_json_from_bundled("claude-code-config.json") + else: + console.print("[dim]Fetching Claude Code config from GitHub...[/dim]") + config_dict = _load_json_from_network(CLAUDE_CODE_CONFIG_URL) + + if config_dict is None: + console.print("[dim]Using bundled fallback Claude Code config[/dim]") + config_dict = _load_json_from_bundled("claude-code-config.json") + + if config_dict is None: + console.print("[red]Failed to load Claude Code configuration[/red]") + return None + + try: + return ClaudeCodeConfig.model_validate(config_dict) + except Exception as e: + console.print(f"[red]Claude Code configuration validation failed: {e}[/red]") + return None diff --git a/packages/smartem-workspace/smartem_workspace/config/repos.json b/packages/smartem-workspace/smartem_workspace/config/repos.json index 01d8e95..33ce5f5 100644 --- a/packages/smartem-workspace/smartem_workspace/config/repos.json +++ b/packages/smartem-workspace/smartem_workspace/config/repos.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "version": "1.0.0", "links": { - "docs": "https://diamondlightsource.github.io/smartem-decisions/", + "docs": "https://diamondlightsource.github.io/smartem-devtools/", "projectBoard": "https://github.com/orgs/DiamondLightSource/projects/51/views/1" }, "presets": { @@ -358,46 +358,5 @@ } ] } - ], - "claudeConfig": { - "skills": [ - { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, - { "name": "devops", "path": "claude-code/shared/skills/devops" }, - { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, - { "name": "git", "path": "claude-code/shared/skills/git" }, - { "name": "github", "path": "claude-code/shared/skills/github" }, - { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, - { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } - ], - "defaultPermissions": { - "allow": [ - "Bash(git:*)", - "Bash(ls:*)", - "Bash(cat:*)", - "WebSearch", - "mcp__serena__*" - ] - } - }, - "serenaConfig": { - "languages": ["typescript", "python"], - "encoding": "utf-8", - "ignoreAllFilesInGitignore": true, - "projectName": "smartem-workspace" - }, - "mcpConfig": { - "serena": { - "command": "uvx", - "args": [ - "--from", - "git+https://github.com/oraios/serena", - "serena", - "start-mcp-server", - "--context", - "ide-assistant", - "--project", - "${PWD}" - ] - } - } + ] } diff --git a/packages/smartem-workspace/smartem_workspace/config/schema.py b/packages/smartem-workspace/smartem_workspace/config/schema.py index ed59b0f..8034917 100644 --- a/packages/smartem-workspace/smartem_workspace/config/schema.py +++ b/packages/smartem-workspace/smartem_workspace/config/schema.py @@ -98,8 +98,20 @@ class McpConfig(BaseModel): serena: McpServerConfig +class ClaudeCodeConfig(BaseModel): + """Claude Code integration configuration (from claude-code-config.json).""" + + model_config = ConfigDict(populate_by_name=True) + + version: str = "1.0.0" + description: str = "" + claudeConfig: ClaudeConfig = Field(alias="claudeConfig") + serenaConfig: SerenaConfig = Field(alias="serenaConfig") + mcpConfig: McpConfig = Field(alias="mcpConfig") + + class ReposConfig(BaseModel): - """Root configuration schema.""" + """Repository configuration schema (from repos.json).""" model_config = ConfigDict(populate_by_name=True) @@ -107,9 +119,6 @@ class ReposConfig(BaseModel): links: ExternalLinks presets: dict[str, Preset] organizations: list[Organization] - claudeConfig: ClaudeConfig = Field(alias="claudeConfig") - serenaConfig: SerenaConfig = Field(alias="serenaConfig") - mcpConfig: McpConfig = Field(alias="mcpConfig") def get_preset(self, name: str) -> Preset | None: """Get a preset by name.""" diff --git a/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py b/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py index b238822..8b847fa 100644 --- a/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py +++ b/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py @@ -4,7 +4,7 @@ from rich.console import Console -from smartem_workspace.config.schema import Organization, ReposConfig, Repository +from smartem_workspace.config.schema import ClaudeCodeConfig, Organization, ReposConfig, Repository from smartem_workspace.interactive.prompts import ( confirm, display_selection_summary, @@ -43,9 +43,10 @@ def bootstrap_workspace( workspace_path: Path, preset: str | None = None, interactive: bool = True, - use_ssh: bool = False, + use_ssh: bool | None = None, skip_claude: bool = False, skip_serena: bool = False, + claude_config: ClaudeCodeConfig | None = None, ) -> bool: """ Main bootstrap function to set up a SmartEM workspace. @@ -55,9 +56,10 @@ def bootstrap_workspace( workspace_path: Target directory for workspace preset: Preset name (if provided, skips repo selection) interactive: Enable interactive prompts - use_ssh: Use SSH URLs for cloning + use_ssh: True=force SSH, False=force HTTPS, None=auto-detect for GitHub skip_claude: Skip Claude Code setup skip_serena: Skip Serena MCP setup + claude_config: Claude Code configuration (required if skip_claude is False) Returns: True if setup completed successfully @@ -108,8 +110,8 @@ def bootstrap_workspace( if failed > 0 and interactive and not confirm("Some repos failed to clone. Continue with setup?"): return False - if not skip_claude: - setup_claude_config(config, workspace_path) + if not skip_claude and claude_config: + setup_claude_config(claude_config, workspace_path) if not skip_serena: project_name = workspace_path.name or "smartem-workspace" diff --git a/packages/smartem-workspace/smartem_workspace/setup/claude.py b/packages/smartem-workspace/smartem_workspace/setup/claude.py index 6bae15f..25cd658 100644 --- a/packages/smartem-workspace/smartem_workspace/setup/claude.py +++ b/packages/smartem-workspace/smartem_workspace/setup/claude.py @@ -6,13 +6,13 @@ from rich.console import Console -from smartem_workspace.config.schema import ReposConfig +from smartem_workspace.config.schema import ClaudeCodeConfig console = Console() def setup_claude_config( - config: ReposConfig, + config: ClaudeCodeConfig, workspace_path: Path, ) -> bool: """ diff --git a/packages/smartem-workspace/smartem_workspace/setup/repos.py b/packages/smartem-workspace/smartem_workspace/setup/repos.py index cfd0ce8..27482aa 100644 --- a/packages/smartem-workspace/smartem_workspace/setup/repos.py +++ b/packages/smartem-workspace/smartem_workspace/setup/repos.py @@ -7,13 +7,35 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from smartem_workspace.config.schema import Organization, Repository +from smartem_workspace.utils.git import check_github_ssh_auth console = Console() -def get_repo_url(repo: Repository, use_ssh: bool) -> str: - """Get the clone URL based on preference.""" - return repo.urls.ssh if use_ssh else repo.urls.https +def get_repo_url(repo: Repository, org: Organization, use_ssh: bool | None, github_ssh_ok: bool | None = None) -> str: + """Get the clone URL based on preference or auto-detection. + + Args: + repo: Repository to get URL for + org: Organization the repo belongs to + use_ssh: True=force SSH, False=force HTTPS, None=auto-detect + github_ssh_ok: Cached result of GitHub SSH check (for auto-detect) + + Returns: + Clone URL (SSH or HTTPS) + """ + # Explicit override + if use_ssh is True: + return repo.urls.ssh + if use_ssh is False: + return repo.urls.https + + # Auto-detect mode + if org.provider == "github" and github_ssh_ok: + return repo.urls.ssh + + # Default to HTTPS (GitLab or GitHub without SSH) + return repo.urls.https def get_local_dir(org: Organization) -> str: @@ -25,11 +47,19 @@ def clone_repo( repo: Repository, org: Organization, repos_dir: Path, - use_ssh: bool = False, + use_ssh: bool | None = None, + github_ssh_ok: bool | None = None, ) -> bool: """ Clone a single repository. + Args: + repo: Repository to clone + org: Organization the repo belongs to + repos_dir: Directory to clone into + use_ssh: True=force SSH, False=force HTTPS, None=auto-detect + github_ssh_ok: Cached result of GitHub SSH check + Returns: True if successful or already exists, False on error """ @@ -42,7 +72,7 @@ def clone_repo( console.print(f" [dim]Skipping {repo.name} (already exists)[/dim]") return True - url = get_repo_url(repo, use_ssh) + url = get_repo_url(repo, org, use_ssh, github_ssh_ok) try: result = subprocess.run( @@ -67,7 +97,7 @@ def clone_repo( def clone_repos( repos: list[tuple[Organization, Repository]], workspace_path: Path, - use_ssh: bool = False, + use_ssh: bool | None = None, devtools_first: bool = True, ) -> tuple[int, int]: """ @@ -76,7 +106,7 @@ def clone_repos( Args: repos: List of (org, repo) tuples to clone workspace_path: Root workspace directory - use_ssh: Use SSH URLs instead of HTTPS + use_ssh: True=force SSH, False=force HTTPS, None=auto-detect for GitHub devtools_first: Clone smartem-devtools first (required for config) Returns: @@ -85,6 +115,28 @@ def clone_repos( repos_dir = workspace_path / "repos" repos_dir.mkdir(parents=True, exist_ok=True) + # Check SSH auth once at start for auto-detect mode + github_ssh_ok: bool | None = None + has_github_repos = any(org.provider == "github" for org, _ in repos) + + if use_ssh is None and has_github_repos: + console.print() + console.print("[dim]Checking GitHub SSH authentication...[/dim]") + github_ssh_ok = check_github_ssh_auth() + if github_ssh_ok: + console.print("[green]SSH authentication successful - using SSH for GitHub repos[/green]") + else: + console.print("[yellow]SSH not configured for GitHub - using HTTPS (anonymous/read-only)[/yellow]") + console.print( + "[dim]To enable push access, configure SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh[/dim]" + ) + elif use_ssh is True: + console.print() + console.print("[dim]Using SSH URLs (--git-ssh)[/dim]") + elif use_ssh is False: + console.print() + console.print("[dim]Using HTTPS URLs (--git-https)[/dim]") + success = 0 failed = 0 @@ -101,7 +153,7 @@ def clone_repos( console.print() console.print("[bold]Cloning smartem-devtools (required)...[/bold]") org, repo = devtools - if clone_repo(repo, org, repos_dir, use_ssh): + if clone_repo(repo, org, repos_dir, use_ssh, github_ssh_ok): success += 1 else: failed += 1 @@ -122,7 +174,7 @@ def clone_repos( for org, repo in repos: progress.update(task, description=f"Cloning {org.name}/{repo.name}...") - if clone_repo(repo, org, repos_dir, use_ssh): + if clone_repo(repo, org, repos_dir, use_ssh, github_ssh_ok): success += 1 else: failed += 1 diff --git a/packages/smartem-workspace/smartem_workspace/setup/workspace.py b/packages/smartem-workspace/smartem_workspace/setup/workspace.py index 6d6e818..edb61f2 100644 --- a/packages/smartem-workspace/smartem_workspace/setup/workspace.py +++ b/packages/smartem-workspace/smartem_workspace/setup/workspace.py @@ -36,28 +36,6 @@ def setup_workspace_structure(workspace_path: Path) -> bool: else: console.print(f" [dim]{dir_name}/ already exists[/dim]") - gitignore_path = workspace_path / ".gitignore" - if not gitignore_path.exists(): - gitignore_content = """# Workspace directories (not versioned) -tmp/ -testdata/ - -# IDE -.idea/ -.vscode/ - -# OS -.DS_Store -Thumbs.db - -# Local Claude settings -.claude/settings.local.json -""" - gitignore_path.write_text(gitignore_content) - console.print(" [green]Created .gitignore[/green]") - else: - console.print(" [dim].gitignore already exists[/dim]") - console.print("[green]Workspace structure complete[/green]") return True diff --git a/packages/smartem-workspace/smartem_workspace/utils/git.py b/packages/smartem-workspace/smartem_workspace/utils/git.py index a0e6b1d..fc56379 100644 --- a/packages/smartem-workspace/smartem_workspace/utils/git.py +++ b/packages/smartem-workspace/smartem_workspace/utils/git.py @@ -140,3 +140,64 @@ def has_remote(repo_path: Path, remote: str = "origin") -> bool: if returncode == 0: return remote in stdout.split() return False + + +_ssh_auth_cache: dict[str, bool] = {} + + +def check_github_ssh_auth() -> bool: + """Test if SSH authentication to GitHub works. + + GitHub's SSH test returns exit code 1 on successful authentication + (with message "successfully authenticated"), and exit code 255 or + timeout if SSH is not configured. + + Results are cached to avoid repeated SSH connection attempts. + """ + if "github" in _ssh_auth_cache: + return _ssh_auth_cache["github"] + + try: + result = subprocess.run( + ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@github.com"], + capture_output=True, + text=True, + timeout=10, + ) + # GitHub returns exit code 1 on successful auth with specific message + success = "successfully authenticated" in result.stderr.lower() + _ssh_auth_cache["github"] = success + return success + except (subprocess.TimeoutExpired, FileNotFoundError): + _ssh_auth_cache["github"] = False + return False + + +def check_gitlab_ssh_auth() -> bool: + """Test if SSH authentication to GitLab works. + + Similar to GitHub, GitLab returns a welcome message on successful auth. + Results are cached. + """ + if "gitlab" in _ssh_auth_cache: + return _ssh_auth_cache["gitlab"] + + try: + result = subprocess.run( + ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@gitlab.com"], + capture_output=True, + text=True, + timeout=10, + ) + # GitLab returns exit code 0 on successful auth + success = result.returncode == 0 or "welcome to gitlab" in result.stderr.lower() + _ssh_auth_cache["gitlab"] = success + return success + except (subprocess.TimeoutExpired, FileNotFoundError): + _ssh_auth_cache["gitlab"] = False + return False + + +def clear_ssh_auth_cache() -> None: + """Clear the SSH authentication cache (mainly for testing).""" + _ssh_auth_cache.clear() diff --git a/packages/smartem-workspace/uv.lock b/packages/smartem-workspace/uv.lock index 5961ccd..3c06dd7 100644 --- a/packages/smartem-workspace/uv.lock +++ b/packages/smartem-workspace/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "smartem-workspace" -version = "0.1.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "httpx" },