Skip to content

Commit d2ef1b5

Browse files
committed
Update setup methods for weco skill
1 parent 8a8d853 commit d2ef1b5

File tree

4 files changed

+417
-283
lines changed

4 files changed

+417
-283
lines changed

weco/cli.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,30 @@ def _parse_credit_amount(value: str) -> float:
185185
)
186186

187187

188+
def _add_setup_source_args(parser: argparse.ArgumentParser) -> None:
189+
"""Add common source arguments to a setup subparser."""
190+
source_group = parser.add_mutually_exclusive_group()
191+
source_group.add_argument(
192+
"--local", type=str, metavar="PATH", help="Use a local weco-skill directory (creates symlink for development)"
193+
)
194+
source_group.add_argument("--repo", type=str, metavar="URL", help="Use a different git repo URL (for forks or testing)")
195+
parser.add_argument(
196+
"--ref",
197+
type=str,
198+
metavar="REF",
199+
help="Checkout a specific branch, tag, or commit hash (used with git clone, not --local)",
200+
)
201+
202+
188203
def configure_setup_parser(setup_parser: argparse.ArgumentParser) -> None:
189204
"""Configure the setup command parser and its subcommands."""
190205
setup_subparsers = setup_parser.add_subparsers(dest="tool", help="AI tool to set up")
191-
setup_subparsers.add_parser("claude-code", help="Set up Weco skill for Claude Code")
192-
setup_subparsers.add_parser("cursor", help="Set up Weco rules for Cursor")
206+
207+
claude_parser = setup_subparsers.add_parser("claude-code", help="Set up Weco skill for Claude Code")
208+
_add_setup_source_args(claude_parser)
209+
210+
cursor_parser = setup_subparsers.add_parser("cursor", help="Set up Weco rules for Cursor")
211+
_add_setup_source_args(cursor_parser)
193212

194213

195214
def configure_resume_parser(resume_parser: argparse.ArgumentParser) -> None:

weco/git.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# weco/git.py
2+
"""
3+
Git utilities for command execution and validation.
4+
"""
5+
6+
import pathlib
7+
import shutil
8+
import subprocess
9+
10+
11+
class GitError(Exception):
12+
"""Raised when a git command fails."""
13+
14+
def __init__(self, message: str, stderr: str = ""):
15+
super().__init__(message)
16+
self.stderr = stderr
17+
18+
19+
class GitNotFoundError(Exception):
20+
"""Raised when git is not available on the system."""
21+
22+
pass
23+
24+
25+
def is_available() -> bool:
26+
"""Check if git is available on the system."""
27+
return shutil.which("git") is not None
28+
29+
30+
def is_repo(path: pathlib.Path) -> bool:
31+
"""Check if a directory is a git repository."""
32+
return (path / ".git").is_dir()
33+
34+
35+
def validate_ref(ref: str) -> None:
36+
"""
37+
Validate a git ref to prevent option injection.
38+
39+
Raises:
40+
ValueError: If ref could be interpreted as a git option.
41+
"""
42+
if ref.startswith("-"):
43+
raise ValueError(f"Invalid git ref: {ref!r} (cannot start with '-')")
44+
45+
46+
def validate_repo_url(url: str) -> None:
47+
"""
48+
Validate a git repository URL.
49+
50+
Raises:
51+
ValueError: If URL doesn't match expected patterns.
52+
"""
53+
valid_prefixes = ("git@", "https://", "http://", "ssh://", "file://", "/", "./", "../")
54+
if not any(url.startswith(prefix) for prefix in valid_prefixes):
55+
raise ValueError(f"Invalid repository URL: {url!r}")
56+
57+
58+
def run(*args: str, cwd: pathlib.Path | None = None, error_msg: str = "Git command failed") -> subprocess.CompletedProcess:
59+
"""
60+
Run a git command and return the result.
61+
62+
Args:
63+
*args: Git subcommand and arguments (e.g., "clone", url, path).
64+
Do NOT include "git" — it's prepended automatically.
65+
cwd: Working directory for the command.
66+
error_msg: Message to include in exception on failure.
67+
68+
Raises:
69+
GitError: If the command fails or returns non-zero.
70+
"""
71+
cmd = ["git", *args]
72+
try:
73+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
74+
except Exception as e:
75+
raise GitError(f"{error_msg}: {e}") from e
76+
77+
if result.returncode != 0:
78+
raise GitError(error_msg, result.stderr)
79+
return result
80+
81+
82+
def clone(repo_url: str, dest: pathlib.Path, ref: str | None = None) -> None:
83+
"""
84+
Clone a git repository.
85+
86+
Args:
87+
repo_url: The repository URL to clone.
88+
dest: Destination directory.
89+
ref: Optional branch, tag, or commit to checkout after cloning.
90+
91+
Raises:
92+
GitError: If clone or checkout fails.
93+
"""
94+
run("clone", repo_url, str(dest), error_msg="Failed to clone repository")
95+
if ref:
96+
run("checkout", ref, cwd=dest, error_msg=f"Failed to checkout '{ref}'")
97+
98+
99+
def pull(repo_path: pathlib.Path) -> None:
100+
"""
101+
Pull latest changes in a git repository.
102+
103+
Raises:
104+
GitError: If pull fails.
105+
"""
106+
run("pull", cwd=repo_path, error_msg="Failed to pull repository")
107+
108+
109+
def fetch_and_checkout(repo_path: pathlib.Path, ref: str) -> None:
110+
"""
111+
Fetch all remotes and checkout a specific ref.
112+
113+
Args:
114+
repo_path: Path to the git repository.
115+
ref: Branch, tag, or commit to checkout.
116+
117+
Raises:
118+
GitError: If fetch or checkout fails.
119+
"""
120+
run("fetch", "--all", cwd=repo_path, error_msg="Failed to fetch from repository")
121+
run("checkout", ref, cwd=repo_path, error_msg=f"Failed to checkout '{ref}'")

0 commit comments

Comments
 (0)