From 40ea9d5c1f0495c869245891484a1e56fd4ea1a5 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 13 Apr 2026 22:04:05 +0200 Subject: [PATCH] First version. I can init --- .idea/.gitignore | 10 + .idea/ClaudeInit.iml | 11 ++ .idea/inspectionProfiles/Project_Default.xml | 24 +++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + myclaude/__init__.py | 3 + myclaude/cli.py | 130 +++++++++++++ myclaude/config.py | 61 ++++++ myclaude/fs_ops.py | 174 ++++++++++++++++++ myclaude/git_ops.py | 59 ++++++ pyproject.toml | 19 ++ tests/__init__.py | 0 tests/conftest.py | 24 +++ tests/test_config.py | 50 +++++ tests/test_fs_ops.py | 76 ++++++++ tests/test_git_ops.py | 45 +++++ 18 files changed, 713 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/ClaudeInit.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 myclaude/__init__.py create mode 100644 myclaude/cli.py create mode 100644 myclaude/config.py create mode 100644 myclaude/fs_ops.py create mode 100644 myclaude/git_ops.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_fs_ops.py create mode 100644 tests/test_git_ops.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/ClaudeInit.iml b/.idea/ClaudeInit.iml new file mode 100644 index 0000000..e01c99d --- /dev/null +++ b/.idea/ClaudeInit.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..e34d8e5 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..34d776d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e3632d4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/myclaude/__init__.py b/myclaude/__init__.py new file mode 100644 index 0000000..63e9f57 --- /dev/null +++ b/myclaude/__init__.py @@ -0,0 +1,3 @@ +"""myclaude - Manage Claude skills across projects.""" + +__version__ = "0.1.0" diff --git a/myclaude/cli.py b/myclaude/cli.py new file mode 100644 index 0000000..61dd30c --- /dev/null +++ b/myclaude/cli.py @@ -0,0 +1,130 @@ +"""Command-line interface for myclaude.""" + +from pathlib import Path +from typing import Optional + +import typer + +from myclaude import config as cfg +from myclaude import fs_ops, git_ops + +app = typer.Typer(help="Manage Claude skills across projects.") + + +@app.command() +def init( + project_dir: Path = typer.Argument(..., help="Target project directory"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo SSH URL"), + skill: Optional[list[str]] = typer.Option(None, "--skill", help="Skills to install"), + force: bool = typer.Option(False, "--force", help="Overwrite existing skills"), + keep_tmp: bool = typer.Option(False, "--keep-tmp", help="Keep temporary clone directory for inspection"), +) -> None: + """Initialize a project with Claude skills from the central repo.""" + if repo: + cfg.save_config(repo) + typer.echo(f"Repository URL saved: {repo}") + + try: + repo_url = cfg.get_repo_url() + except (FileNotFoundError, KeyError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + with git_ops.temp_clone(repo_url, keep=keep_tmp) as (repo_path, _): + try: + fs_ops.copy_claude_md_to_project(repo_path, project_dir) + fs_ops.copy_skills_to_project(repo_path, project_dir, skills=skill, force=force) + except (FileExistsError, ValueError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + skill_label = ", ".join(skill) if skill else "all" + typer.echo(f"Initialized '{project_dir}' with skills: {skill_label}") + + +@app.command() +def push( + skill: Optional[list[str]] = typer.Option(None, "--skill", help="Skills to push"), + claude_md: bool = typer.Option(False, "--claude-md", help="Also push CLAUDE.md"), +) -> None: + """Push local skills to the central repo.""" + project_dir = Path.cwd() + + try: + repo_url = cfg.get_repo_url() + except (FileNotFoundError, KeyError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + with git_ops.temp_clone(repo_url) as (repo_path, repo): + try: + fs_ops.copy_skills_to_repo(project_dir, repo_path, skills=skill) + if claude_md: + fs_ops.copy_claude_md_to_repo(project_dir, repo_path) + + pushed_skills = skill if skill else fs_ops.collect_local_skills(project_dir) + commit_message = f"chore: update skills {', '.join(pushed_skills)}" + if claude_md: + commit_message += " + CLAUDE.md" + + git_ops.commit_and_push(repo, commit_message) + except (ValueError, RuntimeError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + typer.echo("Skills pushed successfully.") + + +@app.command() +def status() -> None: + """Compare local skills with the central repo.""" + project_dir = Path.cwd() + + try: + repo_url = cfg.get_repo_url() + except (FileNotFoundError, KeyError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + with git_ops.temp_clone(repo_url) as (repo_path, _): + result = fs_ops.get_skills_status(project_dir, repo_path) + + if not any([result.in_both, result.local_only, result.remote_only]): + typer.echo("No skills found locally or remotely.") + return + + if result.in_both: + typer.echo("Installed:") + for s in result.in_both: + typer.echo(f" [ok] {s}") + + if result.local_only: + typer.echo("Local only (not pushed):") + for s in result.local_only: + typer.echo(f" [+] {s}") + + if result.remote_only: + typer.echo("Remote only (not installed):") + for s in result.remote_only: + typer.echo(f" [-] {s}") + + +@app.command(name="list") +def list_skills() -> None: + """List all skills available in the central repo.""" + try: + repo_url = cfg.get_repo_url() + except (FileNotFoundError, KeyError) as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + with git_ops.temp_clone(repo_url) as (repo_path, _): + skills = fs_ops.list_skills_in_repo(repo_path) + + if not skills: + typer.echo("No skills found in the central repo.") + return + + typer.echo("Available skills:") + for s in skills: + typer.echo(f" - {s}") diff --git a/myclaude/config.py b/myclaude/config.py new file mode 100644 index 0000000..41d2032 --- /dev/null +++ b/myclaude/config.py @@ -0,0 +1,61 @@ +"""Configuration management for myclaude.""" + +import json +from pathlib import Path + +CONFIG_DIR = Path.home() / ".myclaude" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +def save_config(repo_url: str) -> None: + """Save or update the myclaude configuration file. + + Args: + repo_url: The SSH URL of the central git repository. + """ + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + existing = _read_file() if CONFIG_FILE.exists() else {} + existing["repo_url"] = repo_url + with CONFIG_FILE.open("w", encoding="utf-8") as f: + json.dump(existing, f, indent=2) + + +def load_config() -> dict: + """Load the myclaude configuration file. + + Returns: + The configuration as a dictionary. + + Raises: + FileNotFoundError: If the configuration file does not exist. + """ + if not CONFIG_FILE.exists(): + raise FileNotFoundError( + f"Configuration file not found at {CONFIG_FILE}. " + "Run 'myclaude init . --repo ' first." + ) + return _read_file() + + +def get_repo_url() -> str: + """Get the configured central repository URL. + + Returns: + The SSH URL of the central git repository. + + Raises: + FileNotFoundError: If the configuration file does not exist. + KeyError: If repo_url is not found in the configuration. + """ + config = load_config() + if "repo_url" not in config: + raise KeyError( + "No 'repo_url' found in configuration. " + "Run 'myclaude init . --repo ' first." + ) + return config["repo_url"] + + +def _read_file() -> dict: + with CONFIG_FILE.open("r", encoding="utf-8") as f: + return json.load(f) diff --git a/myclaude/fs_ops.py b/myclaude/fs_ops.py new file mode 100644 index 0000000..5faddc8 --- /dev/null +++ b/myclaude/fs_ops.py @@ -0,0 +1,174 @@ +"""File system operations for myclaude.""" + +import shutil +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class SkillsStatus: + """Diff between local and remote skills. + + Attributes: + local_only: Skills present locally but not in the remote repo. + remote_only: Skills present in the remote repo but not locally. + in_both: Skills present in both local and remote. + """ + + local_only: list[str] + remote_only: list[str] + in_both: list[str] + + +def copy_skills_to_project( + repo_dir: Path, + project_dir: Path, + skills: list[str] | None = None, + force: bool = False, +) -> None: + """Copy skills from the repo to the project's .claude/skills directory. + + Args: + repo_dir: Path to the cloned central repository. + project_dir: Path to the target project directory. + skills: List of skill names to copy. If None, copies all skills. + force: If True, overwrites the existing skills directory. + + Raises: + FileExistsError: If .claude/skills already exists and force is False. + ValueError: If a requested skill does not exist in the repo. + """ + skills_dst = project_dir / ".claude" / "skills" + + if skills_dst.exists() and not force: + raise FileExistsError( + f"Directory '{skills_dst}' already exists. Use --force to overwrite." + ) + + available = list_skills_in_dir(repo_dir / "skills") + skills_to_copy = _resolve_skills(skills, available) + + if skills_dst.exists() and force: + shutil.rmtree(skills_dst) + + skills_dst.mkdir(parents=True, exist_ok=True) + + for skill in skills_to_copy: + shutil.copytree(repo_dir / "skills" / skill, skills_dst / skill) + + +def copy_claude_md_to_project(repo_dir: Path, project_dir: Path) -> None: + """Copy CLAUDE.md from the repo to the project root. + + Args: + repo_dir: Path to the cloned central repository. + project_dir: Path to the target project directory. + """ + shutil.copy2(repo_dir / "CLAUDE.md", project_dir / "CLAUDE.md") + + +def copy_skills_to_repo( + project_dir: Path, + repo_dir: Path, + skills: list[str] | None = None, +) -> None: + """Copy skills from the project to the central repository. + + Args: + project_dir: Path to the source project directory. + repo_dir: Path to the cloned central repository. + skills: List of skill names to push. If None, pushes all local skills. + + Raises: + ValueError: If a requested skill does not exist locally. + """ + available = collect_local_skills(project_dir) + skills_to_push = _resolve_skills(skills, available) + + for skill in skills_to_push: + dst = repo_dir / "skills" / skill + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(project_dir / ".claude" / "skills" / skill, dst) + + +def copy_claude_md_to_repo(project_dir: Path, repo_dir: Path) -> None: + """Copy CLAUDE.md from the project to the central repository. + + Args: + project_dir: Path to the source project directory. + repo_dir: Path to the cloned central repository. + """ + shutil.copy2(project_dir / "CLAUDE.md", repo_dir / "CLAUDE.md") + + +def collect_local_skills(project_dir: Path) -> list[str]: + """List skill names present in the project's .claude/skills directory. + + Args: + project_dir: Path to the project directory. + + Returns: + Sorted list of skill directory names. + """ + return list_skills_in_dir(project_dir / ".claude" / "skills") + + +def list_skills_in_repo(repo_dir: Path) -> list[str]: + """List skill names available in the central repository. + + Args: + repo_dir: Path to the cloned central repository. + + Returns: + Sorted list of skill directory names. + """ + return list_skills_in_dir(repo_dir / "skills") + + +def get_skills_status(project_dir: Path, repo_dir: Path) -> SkillsStatus: + """Compare local skills with those available in the central repo. + + Args: + project_dir: Path to the local project directory. + repo_dir: Path to the cloned central repository. + + Returns: + A SkillsStatus instance describing the diff. + """ + local = set(collect_local_skills(project_dir)) + remote = set(list_skills_in_repo(repo_dir)) + return SkillsStatus( + local_only=sorted(local - remote), + remote_only=sorted(remote - local), + in_both=sorted(local & remote), + ) + + +def list_skills_in_dir(directory: Path) -> list[str]: + """List subdirectory names inside a directory. + + Args: + directory: Path to inspect. + + Returns: + Sorted list of subdirectory names, or empty list if directory + does not exist. + """ + if not directory.exists(): + return [] + return sorted(d.name for d in directory.iterdir() if d.is_dir()) + + +def _resolve_skills( + requested: list[str] | None, + available: list[str], +) -> list[str]: + if requested is None: + return available + unknown = set(requested) - set(available) + if unknown: + raise ValueError( + f"The following skills were not found: {', '.join(sorted(unknown))}" + ) + return requested diff --git a/myclaude/git_ops.py b/myclaude/git_ops.py new file mode 100644 index 0000000..3d78b55 --- /dev/null +++ b/myclaude/git_ops.py @@ -0,0 +1,59 @@ +"""Git operations for myclaude using GitPython.""" + +import shutil +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Generator + +from git import GitCommandError, Repo + + +@contextmanager +def temp_clone(repo_url: str, keep: bool = False) -> Generator[tuple[Path, Repo], None, None]: + """Clone a git repository into a temporary directory. + + The temporary directory is automatically cleaned up on exit unless + keep is True, in which case it is preserved for inspection. + + Args: + repo_url: The SSH URL of the repository to clone. + keep: If True, the temporary directory is not deleted after use. + + Yields: + A tuple of (path to the cloned repo, Repo object). + + Raises: + RuntimeError: If the clone operation fails. + """ + tmp_dir = tempfile.mkdtemp() + try: + try: + repo = Repo.clone_from(repo_url, tmp_dir) + except GitCommandError as e: + raise RuntimeError( + f"Failed to clone repository '{repo_url}': {e}" + ) from e + yield Path(tmp_dir), repo + finally: + if keep: + print(f"[debug] Temporary directory kept at: {tmp_dir}") + else: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def commit_and_push(repo: Repo, message: str) -> None: + """Stage all changes, commit, and push to the remote origin. + + Args: + repo: A GitPython Repo object with an 'origin' remote configured. + message: The commit message. + + Raises: + RuntimeError: If there are no changes to commit. + """ + repo.git.add(A=True) + if not repo.is_dirty(index=True, working_tree=False): + raise RuntimeError("Nothing to commit: no changes detected.") + repo.index.commit(message) + repo.remotes.origin.push() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0f8b5f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "myclaude" +version = "0.1.0" +description = "CLI tool to manage Claude skills across projects" +requires-python = ">=3.12" +dependencies = [ + "gitpython>=3.1", + "typer>=0.12", +] + +[project.scripts] +myclaude = "myclaude.cli:app" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d1ec753 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +"""Shared fixtures for myclaude tests.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_repo(tmp_path: Path) -> Path: + """Create a minimal repo directory structure with two skills and a CLAUDE.md.""" + repo = tmp_path / "repo" + for skill in ("skill_a", "skill_b"): + (repo / "skills" / skill).mkdir(parents=True) + (repo / "skills" / skill / "SKILL.md").write_text(f"# {skill}") + (repo / "CLAUDE.md").write_text("# Claude Template") + return repo + + +@pytest.fixture +def tmp_project(tmp_path: Path) -> Path: + """Create an empty project directory.""" + project = tmp_path / "project" + project.mkdir() + return project diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7124069 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,50 @@ +"""Tests for myclaude.config module.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from myclaude.config import load_config, save_config + + +@pytest.mark.parametrize("repo_url", [ + "git@gitea.example.com:user/skills.git", + "git@gitea.internal:org/claude.git", +]) +def test_i_can_save_config(tmp_path: Path, repo_url: str) -> None: + config_file = tmp_path / "config.json" + with patch("myclaude.config.CONFIG_DIR", tmp_path), \ + patch("myclaude.config.CONFIG_FILE", config_file): + save_config(repo_url) + content = json.loads(config_file.read_text()) + assert content["repo_url"] == repo_url + + +@pytest.mark.parametrize("repo_url", [ + "git@gitea.example.com:user/skills.git", + "git@gitea.internal:org/claude.git", +]) +def test_i_can_load_config(tmp_path: Path, repo_url: str) -> None: + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"repo_url": repo_url})) + with patch("myclaude.config.CONFIG_FILE", config_file): + result = load_config() + assert result["repo_url"] == repo_url + + +def test_i_can_update_repo_url(tmp_path: Path) -> None: + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"repo_url": "git@old.example.com:user/old.git"})) + with patch("myclaude.config.CONFIG_DIR", tmp_path), \ + patch("myclaude.config.CONFIG_FILE", config_file): + save_config("git@new.example.com:user/new.git") + content = json.loads(config_file.read_text()) + assert content["repo_url"] == "git@new.example.com:user/new.git" + + +def test_i_cannot_load_config_when_file_missing(tmp_path: Path) -> None: + with patch("myclaude.config.CONFIG_FILE", tmp_path / "config.json"): + with pytest.raises(FileNotFoundError): + load_config() diff --git a/tests/test_fs_ops.py b/tests/test_fs_ops.py new file mode 100644 index 0000000..a229633 --- /dev/null +++ b/tests/test_fs_ops.py @@ -0,0 +1,76 @@ +"""Tests for myclaude.fs_ops module.""" + +from pathlib import Path + +import pytest + +from myclaude.fs_ops import ( + collect_local_skills, + copy_claude_md_to_project, + copy_skills_to_project, + copy_skills_to_repo, +) + + +def test_i_can_copy_all_skills(tmp_project: Path, tmp_repo: Path) -> None: + copy_skills_to_project(tmp_repo, tmp_project) + assert (tmp_project / ".claude" / "skills" / "skill_a" / "SKILL.md").exists() + assert (tmp_project / ".claude" / "skills" / "skill_b" / "SKILL.md").exists() + + +@pytest.mark.parametrize("requested,expected", [ + (["skill_a"], ["skill_a"]), + (["skill_a", "skill_b"], ["skill_a", "skill_b"]), +]) +def test_i_can_copy_filtered_skills( + tmp_project: Path, + tmp_repo: Path, + requested: list[str], + expected: list[str], +) -> None: + copy_skills_to_project(tmp_repo, tmp_project, skills=requested) + installed = collect_local_skills(tmp_project) + assert installed == sorted(expected) + + +def test_i_can_copy_claude_md(tmp_project: Path, tmp_repo: Path) -> None: + copy_claude_md_to_project(tmp_repo, tmp_project) + assert (tmp_project / "CLAUDE.md").read_text() == "# Claude Template" + + +def test_i_cannot_init_when_skills_dir_exists(tmp_project: Path, tmp_repo: Path) -> None: + (tmp_project / ".claude" / "skills").mkdir(parents=True) + with pytest.raises(FileExistsError): + copy_skills_to_project(tmp_repo, tmp_project) + + +def test_i_can_force_overwrite_skills_dir(tmp_project: Path, tmp_repo: Path) -> None: + skills_dir = tmp_project / ".claude" / "skills" + (skills_dir / "old_skill").mkdir(parents=True) + copy_skills_to_project(tmp_repo, tmp_project, force=True) + assert not (skills_dir / "old_skill").exists() + assert (skills_dir / "skill_a").exists() + + +@pytest.mark.parametrize("unknown_skill", ["ghost", "unknown", "skill99"]) +def test_i_cannot_copy_unknown_skill( + tmp_project: Path, + tmp_repo: Path, + unknown_skill: str, +) -> None: + with pytest.raises(ValueError, match=unknown_skill): + copy_skills_to_project(tmp_repo, tmp_project, skills=[unknown_skill]) + + +def test_i_can_collect_local_skills(tmp_project: Path) -> None: + for skill in ("skill_a", "skill_b"): + (tmp_project / ".claude" / "skills" / skill).mkdir(parents=True) + assert collect_local_skills(tmp_project) == ["skill_a", "skill_b"] + + +def test_i_can_copy_skills_to_repo(tmp_project: Path, tmp_repo: Path) -> None: + skill_dir = tmp_project / ".claude" / "skills" / "skill_a" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Updated A") + copy_skills_to_repo(tmp_project, tmp_repo) + assert (tmp_repo / "skills" / "skill_a" / "SKILL.md").read_text() == "# Updated A" diff --git a/tests/test_git_ops.py b/tests/test_git_ops.py new file mode 100644 index 0000000..4cdaef6 --- /dev/null +++ b/tests/test_git_ops.py @@ -0,0 +1,45 @@ +"""Tests for myclaude.git_ops module.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from git import GitCommandError + +from myclaude.git_ops import commit_and_push, temp_clone + + +def test_i_can_clone_repo_to_temp_dir() -> None: + mock_repo = MagicMock() + with patch("myclaude.git_ops.Repo.clone_from", return_value=mock_repo) as mock_clone: + with temp_clone("git@gitea.example.com:user/skills.git") as (path, repo): + mock_clone.assert_called_once() + assert isinstance(path, Path) + assert repo is mock_repo + + +@pytest.mark.parametrize("bad_url", ["not-a-url", "", "http://no-ssh.com/repo"]) +def test_i_cannot_clone_invalid_repo(bad_url: str) -> None: + with patch( + "myclaude.git_ops.Repo.clone_from", + side_effect=GitCommandError("clone", 128), + ): + with pytest.raises(RuntimeError, match="Failed to clone"): + with temp_clone(bad_url): + pass + + +def test_i_can_commit_and_push() -> None: + mock_repo = MagicMock() + mock_repo.is_dirty.return_value = True + commit_and_push(mock_repo, "chore: update skills skill_a") + mock_repo.git.add.assert_called_once_with(A=True) + mock_repo.index.commit.assert_called_once_with("chore: update skills skill_a") + mock_repo.remotes.origin.push.assert_called_once() + + +def test_i_cannot_push_when_nothing_to_commit() -> None: + mock_repo = MagicMock() + mock_repo.is_dirty.return_value = False + with pytest.raises(RuntimeError, match="Nothing to commit"): + commit_and_push(mock_repo, "chore: update skills skill_a")