First version. I can init

This commit is contained in:
2026-04-13 22:04:05 +02:00
commit 40ea9d5c1f
18 changed files with 713 additions and 0 deletions
+10
View File
@@ -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/
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/myclaude" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="jdk" jdkName="3.14 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/ClaudeInit/bin/python)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+24
View File
@@ -0,0 +1,24 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyInitNewSignatureInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="bson" />
<option value="argon2-cffi" />
<option value="argon2-cffi-bindings" />
<option value="mydbengine" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyStubPackagesCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredStubPackages">
<list>
<option value="pandas-stubs~=2.2.3" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="3.14 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/ClaudeInit/bin/python)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="3.14 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/ClaudeInit/bin/python)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ClaudeInit.iml" filepath="$PROJECT_DIR$/.idea/ClaudeInit.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+3
View File
@@ -0,0 +1,3 @@
"""myclaude - Manage Claude skills across projects."""
__version__ = "0.1.0"
+130
View File
@@ -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}")
+61
View File
@@ -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 <url>' 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 <url>' first."
)
return config["repo_url"]
def _read_file() -> dict:
with CONFIG_FILE.open("r", encoding="utf-8") as f:
return json.load(f)
+174
View File
@@ -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
+59
View File
@@ -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()
+19
View File
@@ -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"]
View File
+24
View File
@@ -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
+50
View File
@@ -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()
+76
View File
@@ -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"
+45
View File
@@ -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")