First version. I can init
This commit is contained in:
Generated
+10
@@ -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/
|
||||||
Generated
+11
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+7
@@ -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>
|
||||||
Generated
+8
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user