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
+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()