First version. I can init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""myclaude - Manage Claude skills across projects."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
+130
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user