weco 0.3.10__py3-none-any.whl → 0.3.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
weco/cli.py CHANGED
@@ -185,11 +185,30 @@ def configure_credits_parser(credits_parser: argparse.ArgumentParser) -> None:
185
185
  )
186
186
 
187
187
 
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
+
188
203
  def configure_setup_parser(setup_parser: argparse.ArgumentParser) -> None:
189
204
  """Configure the setup command parser and its subcommands."""
190
205
  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)
193
212
 
194
213
 
195
214
  def configure_resume_parser(resume_parser: argparse.ArgumentParser) -> None:
weco/git.py ADDED
@@ -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}'")
weco/setup.py CHANGED
@@ -4,18 +4,43 @@ Setup commands for integrating Weco with various AI tools.
4
4
  """
5
5
 
6
6
  import pathlib
7
- import shutil
8
- import subprocess
7
+ import sys
8
+
9
9
  from rich.console import Console
10
10
  from rich.prompt import Confirm
11
11
 
12
+ from . import git
13
+ from .utils import copy_directory, copy_file, read_from_path, remove_directory, write_to_path
14
+
15
+
16
+ # =============================================================================
17
+ # Exceptions
18
+ # =============================================================================
19
+
20
+
21
+ class SetupError(Exception):
22
+ """Base exception for setup failures."""
23
+
24
+ pass
25
+
26
+
27
+ class InvalidLocalRepoError(SetupError):
28
+ """Raised when a local path is not a valid skill repository."""
29
+
30
+ pass
31
+
32
+
33
+ # =============================================================================
34
+ # Path constants
35
+ # =============================================================================
36
+
12
37
  # Claude Code paths
13
38
  CLAUDE_DIR = pathlib.Path.home() / ".claude"
14
39
  CLAUDE_SKILLS_DIR = CLAUDE_DIR / "skills"
15
- CLAUDE_MD_PATH = CLAUDE_DIR / "CLAUDE.md"
16
40
  WECO_SKILL_DIR = CLAUDE_SKILLS_DIR / "weco"
17
41
  WECO_SKILL_REPO = "git@github.com:WecoAI/weco-skill.git"
18
- WECO_RULES_SNIPPET_PATH = WECO_SKILL_DIR / "rules-snippet.md"
42
+ WECO_CLAUDE_SNIPPET_PATH = WECO_SKILL_DIR / "snippets" / "claude.md"
43
+ WECO_CLAUDE_MD_PATH = WECO_SKILL_DIR / "CLAUDE.md"
19
44
 
20
45
  # Cursor paths
21
46
  CURSOR_DIR = pathlib.Path.home() / ".cursor"
@@ -23,345 +48,248 @@ CURSOR_RULES_DIR = CURSOR_DIR / "rules"
23
48
  CURSOR_WECO_RULES_PATH = CURSOR_RULES_DIR / "weco.mdc"
24
49
  CURSOR_SKILLS_DIR = CURSOR_DIR / "skills"
25
50
  CURSOR_WECO_SKILL_DIR = CURSOR_SKILLS_DIR / "weco"
26
- CURSOR_RULES_SNIPPET_PATH = CURSOR_WECO_SKILL_DIR / "rules-snippet.md"
51
+ CURSOR_RULES_SNIPPET_PATH = CURSOR_WECO_SKILL_DIR / "snippets" / "cursor.md"
27
52
 
28
- # Delimiters for agent rules files - allows automatic updates
29
- WECO_RULES_BEGIN_DELIMITER = "<!-- BEGIN WECO_RULES -->"
30
- WECO_RULES_END_DELIMITER = "<!-- END WECO_RULES -->"
53
+ # Files/directories to skip when copying local repos
54
+ _COPY_IGNORE_PATTERNS = {".git", "__pycache__", ".DS_Store"}
31
55
 
32
56
 
33
- def is_git_available() -> bool:
34
- """Check if git is available on the system."""
35
- return shutil.which("git") is not None
57
+ # =============================================================================
58
+ # Domain helpers
59
+ # =============================================================================
36
60
 
37
61
 
38
- def read_rules_snippet(snippet_path: pathlib.Path, console: Console) -> str | None:
39
- """
40
- Read the rules snippet from the cloned skill repository.
62
+ def generate_cursor_mdc_content(snippet_content: str) -> str:
63
+ """Generate Cursor MDC file content with YAML frontmatter."""
64
+ return f"""---
65
+ description: Weco code optimization skill. Weco automates optimization by iteratively refining code against any metric you define — invoke for speed, accuracy, latency, cost, or anything else you can measure.
66
+ alwaysApply: true
67
+ ---
68
+ {snippet_content}
69
+ """
41
70
 
42
- Args:
43
- snippet_path: Path to the rules-snippet.md file.
44
- console: Rich console for output.
45
71
 
46
- Returns:
47
- The snippet content wrapped in delimiters, or None if not found.
72
+ def validate_local_skill_repo(local_path: pathlib.Path) -> None:
48
73
  """
49
- if not snippet_path.exists():
50
- console.print(f"[bold red]Error:[/] Snippet file not found at {snippet_path}")
51
- return None
74
+ Validate that a local path is a valid weco-skill repository.
52
75
 
53
- try:
54
- snippet_content = snippet_path.read_text().strip()
55
- return f"\n{WECO_RULES_BEGIN_DELIMITER}\n{snippet_content}\n{WECO_RULES_END_DELIMITER}\n"
56
- except Exception as e:
57
- console.print(f"[bold red]Error:[/] Failed to read snippet file: {e}")
58
- return None
59
-
60
-
61
- def read_rules_snippet_raw(snippet_path: pathlib.Path, console: Console) -> str | None:
76
+ Raises:
77
+ InvalidLocalRepoError: If validation fails.
62
78
  """
63
- Read the raw rules snippet from the cloned skill repository (without delimiters).
79
+ if not local_path.exists():
80
+ raise InvalidLocalRepoError(f"Local path does not exist: {local_path}")
81
+ if not local_path.is_dir():
82
+ raise InvalidLocalRepoError(f"Local path is not a directory: {local_path}")
83
+ if not (local_path / "SKILL.md").exists():
84
+ raise InvalidLocalRepoError(
85
+ f"Local path does not appear to be a weco-skill repository (expected SKILL.md at {local_path / 'SKILL.md'})"
86
+ )
64
87
 
65
- Args:
66
- snippet_path: Path to the rules-snippet.md file.
67
- console: Rich console for output.
68
88
 
69
- Returns:
70
- The raw snippet content, or None if not found.
71
- """
72
- if not snippet_path.exists():
73
- console.print(f"[bold red]Error:[/] Snippet file not found at {snippet_path}")
74
- return None
89
+ # =============================================================================
90
+ # Installation functions
91
+ # =============================================================================
75
92
 
76
- try:
77
- return snippet_path.read_text().strip()
78
- except Exception as e:
79
- console.print(f"[bold red]Error:[/] Failed to read snippet file: {e}")
80
- return None
81
93
 
94
+ def install_skill_from_git(skill_dir: pathlib.Path, console: Console, repo_url: str | None, ref: str | None) -> None:
95
+ """
96
+ Clone or update skill from git.
82
97
 
83
- def generate_cursor_mdc_content(snippet_content: str) -> str:
98
+ Raises:
99
+ git.GitNotFoundError: If git is not available.
100
+ git.GitError: If git operations fail.
84
101
  """
85
- Generate Cursor MDC file content with YAML frontmatter.
102
+ if not git.is_available():
103
+ raise git.GitNotFoundError("git is not installed or not in PATH")
86
104
 
87
- Args:
88
- snippet_content: The raw rules snippet content.
105
+ skill_dir.parent.mkdir(parents=True, exist_ok=True)
89
106
 
90
- Returns:
91
- MDC formatted content with frontmatter.
92
- """
93
- return f"""---
94
- description: Weco code optimization skill - invoke for speed, accuracy, loss optimization
95
- alwaysApply: true
96
- ---
97
- {snippet_content}
98
- """
107
+ if skill_dir.exists():
108
+ if git.is_repo(skill_dir):
109
+ console.print(f"[cyan]Updating existing skill at {skill_dir}...[/]")
110
+ if ref:
111
+ git.fetch_and_checkout(skill_dir, ref)
112
+ console.print(f"[green]Checked out {ref}.[/]")
113
+ else:
114
+ git.pull(skill_dir)
115
+ console.print("[green]Skill updated successfully.[/]")
116
+ return
99
117
 
118
+ # Not a git repo — clear and re-clone
119
+ console.print(f"[cyan]Removing existing directory at {skill_dir}...[/]")
120
+ remove_directory(skill_dir)
100
121
 
101
- def is_git_repo(path: pathlib.Path) -> bool:
102
- """Check if a directory is a git repository."""
103
- return (path / ".git").is_dir()
122
+ console.print(f"[cyan]Cloning Weco skill to {skill_dir}...[/]")
123
+ git.clone(repo_url or WECO_SKILL_REPO, skill_dir, ref=ref)
124
+ if ref:
125
+ console.print(f"[green]Cloned and checked out {ref}.[/]")
126
+ else:
127
+ console.print("[green]Skill cloned successfully.[/]")
104
128
 
105
129
 
106
- def clone_skill_repo(skill_dir: pathlib.Path, console: Console) -> bool:
130
+ def install_skill_from_local(skill_dir: pathlib.Path, console: Console, local_path: pathlib.Path) -> None:
107
131
  """
108
- Clone or update the weco-skill repository to the specified directory.
132
+ Copy skill from local path.
109
133
 
110
- Args:
111
- skill_dir: The directory to clone/update the skill repository in.
112
- console: Rich console for output.
113
-
114
- Returns:
115
- True if successful, False otherwise.
134
+ Raises:
135
+ InvalidLocalRepoError: If local path is invalid.
136
+ OSError: If copy fails.
116
137
  """
117
- if not is_git_available():
118
- console.print("[bold red]Error:[/] git is not installed or not in PATH.")
119
- console.print("Please install git and try again.")
120
- return False
138
+ validate_local_skill_repo(local_path)
121
139
 
122
- # Ensure the parent skills directory exists
123
140
  skill_dir.parent.mkdir(parents=True, exist_ok=True)
124
141
 
125
142
  if skill_dir.exists():
126
- if is_git_repo(skill_dir):
127
- # Directory exists and is a git repo - pull latest
128
- console.print(f"[cyan]Updating existing skill at {skill_dir}...[/]")
129
- try:
130
- result = subprocess.run(["git", "pull"], cwd=skill_dir, capture_output=True, text=True)
131
- if result.returncode != 0:
132
- console.print("[bold red]Error:[/] Failed to update skill repository.")
133
- console.print(f"[dim]{result.stderr}[/]")
134
- return False
135
- console.print("[green]Skill updated successfully.[/]")
136
- return True
137
- except Exception as e:
138
- console.print(f"[bold red]Error:[/] Failed to update skill repository: {e}")
139
- return False
140
- else:
141
- # Directory exists but is not a git repo
142
- console.print(f"[bold red]Error:[/] Directory {skill_dir} exists but is not a git repository.")
143
- console.print("Please remove it manually and try again.")
144
- return False
145
- else:
146
- # Clone the repository
147
- console.print(f"[cyan]Cloning Weco skill to {skill_dir}...[/]")
148
- try:
149
- result = subprocess.run(["git", "clone", WECO_SKILL_REPO, str(skill_dir)], capture_output=True, text=True)
150
- if result.returncode != 0:
151
- console.print("[bold red]Error:[/] Failed to clone skill repository.")
152
- console.print(f"[dim]{result.stderr}[/]")
153
- return False
154
- console.print("[green]Skill cloned successfully.[/]")
155
- return True
156
- except Exception as e:
157
- console.print(f"[bold red]Error:[/] Failed to clone skill repository: {e}")
158
- return False
159
-
160
-
161
- def update_agent_rules_file(
162
- rules_file: pathlib.Path, snippet_path: pathlib.Path, skill_dir: pathlib.Path, console: Console
163
- ) -> bool:
164
- """
165
- Update an agent's rules file with the Weco skill reference.
143
+ console.print(f"[cyan]Removing existing directory at {skill_dir}...[/]")
144
+ remove_directory(skill_dir)
166
145
 
167
- Uses delimiters to allow automatic updates if the snippet changes.
146
+ copy_directory(local_path, skill_dir, ignore_patterns=_COPY_IGNORE_PATTERNS)
147
+ console.print(f"[green]Copied local repo from: {local_path}[/]")
168
148
 
169
- Args:
170
- rules_file: Path to the agent's rules file (e.g., ~/.claude/CLAUDE.md)
171
- snippet_path: Path to the rules-snippet.md file.
172
- skill_dir: Path to the skill directory (for user messaging).
173
- console: Rich console for output.
174
149
 
175
- Returns:
176
- True if updated or user declined, False on error.
177
- """
178
- import re
150
+ def install_skill(
151
+ skill_dir: pathlib.Path, console: Console, local_path: pathlib.Path | None, repo_url: str | None, ref: str | None
152
+ ) -> None:
153
+ """Install skill by copying from local path or cloning from git."""
154
+ if local_path:
155
+ if ref:
156
+ console.print("[bold yellow]Warning:[/] --ref is ignored when using --local")
157
+ install_skill_from_local(skill_dir, console, local_path)
158
+ else:
159
+ install_skill_from_git(skill_dir, console, repo_url, ref)
179
160
 
180
- rules_file_name = rules_file.name
181
161
 
182
- # Read the snippet from the cloned skill repo
183
- snippet_section = read_rules_snippet(snippet_path, console)
184
- if snippet_section is None:
185
- return False
162
+ # =============================================================================
163
+ # Setup commands
164
+ # =============================================================================
186
165
 
187
- # Check if the section already exists with delimiters
188
- existing_content = ""
189
- has_existing_section = False
190
- if rules_file.exists():
191
- try:
192
- existing_content = rules_file.read_text()
193
- has_existing_section = (
194
- WECO_RULES_BEGIN_DELIMITER in existing_content and WECO_RULES_END_DELIMITER in existing_content
195
- )
196
- except Exception as e:
197
- console.print(f"[bold yellow]Warning:[/] Could not read {rules_file_name}: {e}")
198
-
199
- # Determine what action to take
200
- if has_existing_section:
201
- # Check if content is already up to date
202
- pattern = re.escape(WECO_RULES_BEGIN_DELIMITER) + r".*?" + re.escape(WECO_RULES_END_DELIMITER)
203
- match = re.search(pattern, existing_content, re.DOTALL)
204
- if match and match.group(0).strip() == snippet_section.strip():
205
- console.print(f"[dim]{rules_file_name} already contains the latest Weco rules.[/]")
206
- return True
207
-
208
- # Prompt for update
209
- console.print(f"\n[bold yellow]{rules_file_name} Update[/]")
210
- console.print(f"The Weco rules in your {rules_file_name} can be updated to the latest version.")
211
- should_update = Confirm.ask("Would you like to update the Weco section?", default=True)
212
- elif rules_file.exists():
213
- console.print(f"\n[bold yellow]{rules_file_name} Update[/]")
214
- console.print(f"To enable automatic skill discovery, we can add Weco rules to your {rules_file_name} file.")
215
- should_update = Confirm.ask(f"Would you like to update your {rules_file_name}?", default=True)
216
- else:
217
- console.print(f"\n[bold yellow]{rules_file_name} Creation[/]")
218
- console.print(f"To enable automatic skill discovery, we can create a {rules_file_name} file.")
219
- should_update = Confirm.ask(f"Would you like to create {rules_file_name}?", default=True)
220
-
221
- if not should_update:
222
- console.print(f"\n[yellow]Skipping {rules_file_name} update.[/]")
223
- console.print(
224
- "[dim]The Weco skill has been installed but may not be discovered automatically.\n"
225
- f"You can manually reference it at {skill_dir}[/]"
226
- )
227
- return True
228
166
 
229
- # Update or create the file
230
- try:
231
- rules_file.parent.mkdir(parents=True, exist_ok=True)
232
-
233
- if has_existing_section:
234
- # Replace existing section between delimiters
235
- pattern = re.escape(WECO_RULES_BEGIN_DELIMITER) + r".*?" + re.escape(WECO_RULES_END_DELIMITER)
236
- new_content = re.sub(pattern, snippet_section.strip(), existing_content, flags=re.DOTALL)
237
- rules_file.write_text(new_content)
238
- console.print(f"[green]{rules_file_name} updated successfully.[/]")
239
- elif rules_file.exists():
240
- # Append to existing file
241
- with open(rules_file, "a") as f:
242
- f.write(snippet_section)
243
- console.print(f"[green]{rules_file_name} updated successfully.[/]")
244
- else:
245
- # Create new file
246
- with open(rules_file, "w") as f:
247
- f.write(snippet_section.lstrip())
248
- console.print(f"[green]{rules_file_name} created successfully.[/]")
249
- return True
250
- except Exception as e:
251
- console.print(f"[bold red]Error:[/] Failed to update {rules_file_name}: {e}")
252
- return False
253
-
254
-
255
- def setup_claude_code(console: Console) -> bool:
256
- """
257
- Set up Weco skill for Claude Code.
258
-
259
- Returns:
260
- True if setup was successful, False otherwise.
261
- """
167
+ def setup_claude_code(
168
+ console: Console, local_path: pathlib.Path | None = None, repo_url: str | None = None, ref: str | None = None
169
+ ) -> None:
170
+ """Set up Weco skill for Claude Code."""
262
171
  console.print("[bold blue]Setting up Weco for Claude Code...[/]\n")
263
172
 
264
- # Step 1: Clone or update the skill repository
265
- if not clone_skill_repo(WECO_SKILL_DIR, console):
266
- return False
173
+ # Claude Code setup is intentionally "skill-centric":
174
+ # - Install the skill into Claude's skills directory.
175
+ # - `CLAUDE.md` lives *inside* that installed skill folder, so configuration is just
176
+ # copying a file within the skill directory.
177
+ # - There is no separate global config file to reconcile or prompt before overwriting.
178
+ install_skill(WECO_SKILL_DIR, console, local_path, repo_url, ref)
267
179
 
268
- # Step 2: Update CLAUDE.md
269
- if not update_agent_rules_file(CLAUDE_MD_PATH, WECO_RULES_SNIPPET_PATH, WECO_SKILL_DIR, console):
270
- return False
180
+ # Copy snippets/claude.md to CLAUDE.md (skip for local - user manages their own)
181
+ if not local_path:
182
+ copy_file(WECO_CLAUDE_SNIPPET_PATH, WECO_CLAUDE_MD_PATH)
183
+ console.print("[green]CLAUDE.md installed to skill directory.[/]")
271
184
 
272
185
  console.print("\n[bold green]Setup complete![/]")
186
+ if local_path:
187
+ console.print(f"[dim]Skill copied from: {local_path}[/]")
273
188
  console.print(f"[dim]Skill installed at: {WECO_SKILL_DIR}[/]")
274
- return True
275
189
 
276
190
 
277
- def setup_cursor(console: Console) -> bool:
278
- """
279
- Set up Weco rules for Cursor.
280
-
281
- Creates a weco.mdc file in ~/.cursor/rules/ with the Weco optimization rules.
282
-
283
- Returns:
284
- True if setup was successful, False otherwise.
285
- """
191
+ def setup_cursor(
192
+ console: Console, local_path: pathlib.Path | None = None, repo_url: str | None = None, ref: str | None = None
193
+ ) -> None:
194
+ """Set up Weco rules for Cursor."""
286
195
  console.print("[bold blue]Setting up Weco for Cursor...[/]\n")
287
196
 
288
- # Step 1: Clone or update the skill repository to Cursor's path
289
- if not clone_skill_repo(CURSOR_WECO_SKILL_DIR, console):
290
- return False
291
-
292
- # Step 2: Read the rules snippet
293
- snippet_content = read_rules_snippet_raw(CURSOR_RULES_SNIPPET_PATH, console)
294
- if snippet_content is None:
295
- return False
296
-
297
- # Step 3: Check if weco.mdc already exists
197
+ # Cursor setup is intentionally "editor-config-centric":
198
+ # - Install/copy the skill into Cursor's skills directory (so we can read snippets).
199
+ # - The behavior change is controlled by `~/.cursor/rules/weco.mdc`, which is *global*
200
+ # editor state (not part of the installed skill folder).
201
+ # - Because users may have customized that file, we:
202
+ # 1) compute desired content from the snippet
203
+ # 2) check if it is already up to date
204
+ # 3) prompt before creating/updating it
205
+ install_skill(CURSOR_WECO_SKILL_DIR, console, local_path, repo_url, ref)
206
+
207
+ snippet_content = read_from_path(CURSOR_RULES_SNIPPET_PATH)
208
+ mdc_content = generate_cursor_mdc_content(snippet_content.strip())
209
+
210
+ # Check if already up to date
211
+ existing_content = None
298
212
  if CURSOR_WECO_RULES_PATH.exists():
299
213
  try:
300
- existing_content = CURSOR_WECO_RULES_PATH.read_text()
301
- new_content = generate_cursor_mdc_content(snippet_content)
302
- if existing_content.strip() == new_content.strip():
303
- console.print("[dim]weco.mdc already contains the latest Weco rules.[/]")
304
- console.print("\n[bold green]Setup complete![/]")
305
- console.print(f"[dim]Rules file at: {CURSOR_WECO_RULES_PATH}[/]")
306
- return True
307
- except Exception as e:
308
- console.print(f"[bold yellow]Warning:[/] Could not read existing weco.mdc: {e}")
309
-
214
+ existing_content = read_from_path(CURSOR_WECO_RULES_PATH)
215
+ except Exception:
216
+ pass
217
+
218
+ if existing_content is not None and existing_content.strip() == mdc_content.strip():
219
+ console.print("[dim]weco.mdc already contains the latest Weco rules.[/]")
220
+ console.print("\n[bold green]Setup complete![/]")
221
+ console.print(f"[dim]Rules file at: {CURSOR_WECO_RULES_PATH}[/]")
222
+ return
223
+
224
+ # Prompt user for creation/update
225
+ if existing_content is not None:
310
226
  console.print("\n[bold yellow]weco.mdc Update[/]")
311
227
  console.print("The Weco rules file can be updated to the latest version.")
312
- should_update = Confirm.ask("Would you like to update weco.mdc?", default=True)
228
+ if not Confirm.ask("Would you like to update weco.mdc?", default=True):
229
+ console.print("\n[yellow]Skipping weco.mdc update.[/]")
230
+ console.print(f"[dim]Skill installed but rules not configured. Create manually at {CURSOR_WECO_RULES_PATH}[/]")
231
+ return
313
232
  else:
314
233
  console.print("\n[bold yellow]weco.mdc Creation[/]")
315
234
  console.print("To enable Weco optimization rules, we can create a weco.mdc file.")
316
- should_update = Confirm.ask("Would you like to create weco.mdc?", default=True)
317
-
318
- if not should_update:
319
- console.print("\n[yellow]Skipping weco.mdc update.[/]")
320
- console.print(
321
- "[dim]The Weco skill has been installed but rules are not configured.\n"
322
- f"You can manually create the rules file at {CURSOR_WECO_RULES_PATH}[/]"
323
- )
324
- return True
235
+ if not Confirm.ask("Would you like to create weco.mdc?", default=True):
236
+ console.print("\n[yellow]Skipping weco.mdc creation.[/]")
237
+ console.print(f"[dim]Skill installed but rules not configured. Create manually at {CURSOR_WECO_RULES_PATH}[/]")
238
+ return
325
239
 
326
- # Step 4: Write the MDC file
327
- try:
328
- CURSOR_RULES_DIR.mkdir(parents=True, exist_ok=True)
329
- mdc_content = generate_cursor_mdc_content(snippet_content)
330
- CURSOR_WECO_RULES_PATH.write_text(mdc_content)
331
- console.print("[green]weco.mdc created successfully.[/]")
332
- except Exception as e:
333
- console.print(f"[bold red]Error:[/] Failed to write weco.mdc: {e}")
334
- return False
240
+ write_to_path(CURSOR_WECO_RULES_PATH, mdc_content, mkdir=True)
241
+ console.print("[green]weco.mdc created successfully.[/]")
335
242
 
336
243
  console.print("\n[bold green]Setup complete![/]")
244
+ if local_path:
245
+ console.print(f"[dim]Skill copied from: {local_path}[/]")
246
+ console.print(f"[dim]Skill installed at: {CURSOR_WECO_SKILL_DIR}[/]")
337
247
  console.print(f"[dim]Rules file at: {CURSOR_WECO_RULES_PATH}[/]")
338
- return True
248
+
249
+
250
+ # =============================================================================
251
+ # CLI entry point
252
+ # =============================================================================
253
+
254
+ SETUP_HANDLERS = {"claude-code": setup_claude_code, "cursor": setup_cursor}
339
255
 
340
256
 
341
257
  def handle_setup_command(args, console: Console) -> None:
342
258
  """Handle the setup command with its subcommands."""
343
- if args.tool == "claude-code":
344
- success = setup_claude_code(console)
345
- if not success:
346
- import sys
347
-
348
- sys.exit(1)
349
- elif args.tool == "cursor":
350
- success = setup_cursor(console)
351
- if not success:
352
- import sys
353
-
354
- sys.exit(1)
355
- elif args.tool is None:
259
+ available_tools = ", ".join(SETUP_HANDLERS)
260
+
261
+ if args.tool is None:
356
262
  console.print("[bold red]Error:[/] Please specify a tool to set up.")
357
- console.print("Available tools: claude-code, cursor")
263
+ console.print(f"Available tools: {available_tools}")
358
264
  console.print("\nUsage: weco setup <tool>")
359
- import sys
360
-
361
265
  sys.exit(1)
362
- else:
266
+
267
+ handler = SETUP_HANDLERS.get(args.tool)
268
+ if handler is None:
363
269
  console.print(f"[bold red]Error:[/] Unknown tool: {args.tool}")
364
- console.print("Available tools: claude-code, cursor")
365
- import sys
270
+ console.print(f"Available tools: {available_tools}")
271
+ sys.exit(1)
272
+
273
+ # Validate and extract args
274
+ repo_url = getattr(args, "repo", None)
275
+ ref = getattr(args, "ref", None)
276
+ local_path = None
277
+ if hasattr(args, "local") and args.local:
278
+ local_path = pathlib.Path(args.local).expanduser().resolve()
366
279
 
280
+ try:
281
+ if repo_url:
282
+ git.validate_repo_url(repo_url)
283
+ if ref:
284
+ git.validate_ref(ref)
285
+
286
+ handler(console, local_path=local_path, repo_url=repo_url, ref=ref)
287
+
288
+ except git.GitError as e:
289
+ console.print(f"[bold red]Error:[/] {e}")
290
+ if e.stderr:
291
+ console.print(f"[dim]{e.stderr}[/]")
292
+ sys.exit(1)
293
+ except (SetupError, git.GitNotFoundError, FileNotFoundError, OSError, ValueError) as e:
294
+ console.print(f"[bold red]Error:[/] {e}")
367
295
  sys.exit(1)
weco/utils.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import Any, Dict, List, Tuple, Union
2
2
  import json
3
+ import shutil
3
4
  import time
4
5
  import subprocess
5
6
  import psutil
@@ -63,8 +64,19 @@ def read_from_path(fp: pathlib.Path, is_json: bool = False) -> Union[str, Dict[s
63
64
  return f.read()
64
65
 
65
66
 
66
- def write_to_path(fp: pathlib.Path, content: Union[str, Dict[str, Any]], is_json: bool = False) -> None:
67
- """Write content to a file path, optionally as JSON."""
67
+ def write_to_path(fp: pathlib.Path, content: Union[str, Dict[str, Any]], is_json: bool = False, mkdir: bool = False) -> None:
68
+ """
69
+ Write content to a file path, optionally as JSON.
70
+
71
+ Args:
72
+ fp: File path to write to.
73
+ content: Content to write (string or dict for JSON).
74
+ is_json: If True, write as JSON.
75
+ mkdir: If True, create parent directories if they don't exist.
76
+ """
77
+ if mkdir:
78
+ fp.parent.mkdir(parents=True, exist_ok=True)
79
+
68
80
  with fp.open("w", encoding="utf-8") as f:
69
81
  if is_json:
70
82
  json.dump(content, f, indent=4)
@@ -74,6 +86,60 @@ def write_to_path(fp: pathlib.Path, content: Union[str, Dict[str, Any]], is_json
74
86
  raise TypeError("Error writing to file. Please verify the file path and try again.")
75
87
 
76
88
 
89
+ def copy_file(src: pathlib.Path, dest: pathlib.Path, mkdir: bool = False) -> None:
90
+ """
91
+ Copy a single file.
92
+
93
+ Args:
94
+ src: Source file path.
95
+ dest: Destination file path.
96
+ mkdir: If True, create parent directories if they don't exist.
97
+
98
+ Raises:
99
+ FileNotFoundError: If source doesn't exist.
100
+ OSError: If copy fails.
101
+ """
102
+ if not src.exists():
103
+ raise FileNotFoundError(f"Source file not found: {src}")
104
+ if mkdir:
105
+ dest.parent.mkdir(parents=True, exist_ok=True)
106
+ shutil.copy(src, dest)
107
+
108
+
109
+ def copy_directory(src: pathlib.Path, dest: pathlib.Path, ignore_patterns: set[str] | None = None) -> None:
110
+ """
111
+ Copy a directory tree.
112
+
113
+ Args:
114
+ src: Source directory path.
115
+ dest: Destination directory path.
116
+ ignore_patterns: Optional set of file/directory names to skip.
117
+
118
+ Raises:
119
+ FileNotFoundError: If source doesn't exist.
120
+ OSError: If copy fails.
121
+ """
122
+ if not src.exists():
123
+ raise FileNotFoundError(f"Source directory not found: {src}")
124
+
125
+ def ignore_func(_: str, files: list[str]) -> list[str]:
126
+ if not ignore_patterns:
127
+ return []
128
+ return [f for f in files if f in ignore_patterns]
129
+
130
+ shutil.copytree(src, dest, ignore=ignore_func)
131
+
132
+
133
+ def remove_directory(path: pathlib.Path) -> None:
134
+ """
135
+ Remove a directory and all its contents.
136
+
137
+ Does nothing if the directory doesn't exist.
138
+ """
139
+ if path.exists():
140
+ shutil.rmtree(path)
141
+
142
+
77
143
  # Visualization helper functions
78
144
  def smooth_update(
79
145
  live: Live, layout: Layout, sections_to_update: List[Tuple[str, Panel]], transition_delay: float = 0.05
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weco
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: Documentation for `weco`, a CLI for using Weco AI's code optimizer.
5
5
  Author-email: Weco AI Team <contact@weco.ai>
6
6
  License:
@@ -2,18 +2,19 @@ weco/__init__.py,sha256=ClO0uT6GKOA0iSptvP0xbtdycf0VpoPTq37jHtvlhtw,303
2
2
  weco/api.py,sha256=hy0D01x-AJ26DURtKEywQAS7nQgo38A-wAJfPKHGGqM,17395
3
3
  weco/auth.py,sha256=O31Hoj-Loi8DWJJG2LfeWgUMuNqAUeGDpd2ZGjA9Ah0,9997
4
4
  weco/browser.py,sha256=nsqQtLqbNOe9Zhu9Zogc8rMmBMyuDxuHzKZQL_w10Ps,923
5
- weco/cli.py,sha256=WaFkdIOQZIkBcPfWbUhOvUExNeDVdrmax5-tVGY3uNE,14000
5
+ weco/cli.py,sha256=6JDMZ6jNyqaTlzbabzaCxK_UpImh818wabNI5kBsh6M,14775
6
6
  weco/constants.py,sha256=rxL6yrpIzK8zvPTmPqOYl7LUMZ01vUJ9zUqfZD2n-0U,519
7
7
  weco/credits.py,sha256=C08x-TRcLg3ccfKqMGNRY7zBn7t3r7LZ119bxgfztaI,7629
8
+ weco/git.py,sha256=j7JI9nzB7jqEACBsQflU5h4l2bOlg5oWVyEmZ0-ORkI,3377
8
9
  weco/optimizer.py,sha256=l9TrAsie5yPbhq71kStW5jfdz9jfIXuN6Azdn6hUMZo,25224
9
10
  weco/panels.py,sha256=POHt0MdRKDykwUJYXcry92O41lpB9gxna55wFI9abWU,16272
10
- weco/setup.py,sha256=Zv1CbxYHamnoOcVfpCn68ymHgjzCTHHGMKep_WSFLCk,13981
11
+ weco/setup.py,sha256=wh_yFrTML_Qt5QDhhaDyIJQccxId8p_1DjmWYxDq9hc,11393
11
12
  weco/ui.py,sha256=Y7ASIQydwuYFdSYrvme5aGvkplFMi-cjhsnCj5Ebgoc,15092
12
- weco/utils.py,sha256=v_rvgw-ktRoXrpPA2copngI8QDCB8UXmbiN-wAiYvEE,9450
13
+ weco/utils.py,sha256=5MpUFoydk_AFkzZRQQH5ddPA6G_3YI7ocNQBaFXHy6o,11283
13
14
  weco/validation.py,sha256=n5aDuF3BFgwVb4eZ9PuU48nogrseXYNI8S3ePqWZCoc,3736
14
- weco-0.3.10.dist-info/licenses/LICENSE,sha256=9LUfoGHjLPtak2zps2kL2tm65HAZIICx_FbLaRuS4KU,11337
15
- weco-0.3.10.dist-info/METADATA,sha256=1eJxhWSGbdrHcOeBxjmxJ_T6Hruva1YNNn9fxOb59FE,30804
16
- weco-0.3.10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
- weco-0.3.10.dist-info/entry_points.txt,sha256=ixJ2uClALbCpBvnIR6BXMNck8SHAab8eVkM9pIUowcs,39
18
- weco-0.3.10.dist-info/top_level.txt,sha256=F0N7v6e2zBSlsorFv-arAq2yDxQbzX3KVO8GxYhPUeE,5
19
- weco-0.3.10.dist-info/RECORD,,
15
+ weco-0.3.11.dist-info/licenses/LICENSE,sha256=9LUfoGHjLPtak2zps2kL2tm65HAZIICx_FbLaRuS4KU,11337
16
+ weco-0.3.11.dist-info/METADATA,sha256=_zThzvB4LDG0REEW_hCMGzsVgXRnNAG-xRZ3o3RBipU,30804
17
+ weco-0.3.11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
18
+ weco-0.3.11.dist-info/entry_points.txt,sha256=ixJ2uClALbCpBvnIR6BXMNck8SHAab8eVkM9pIUowcs,39
19
+ weco-0.3.11.dist-info/top_level.txt,sha256=F0N7v6e2zBSlsorFv-arAq2yDxQbzX3KVO8GxYhPUeE,5
20
+ weco-0.3.11.dist-info/RECORD,,
File without changes