Skip to content

Commit bc2b7e1

Browse files
committed
Move setup command to commands/ dir and refactor
1 parent c4da44b commit bc2b7e1

9 files changed

Lines changed: 339 additions & 510 deletions

File tree

tests/test_cli.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,7 @@ def test_module_execution_invokes_cli_help():
7878
"""Running the module directly should invoke the CLI entrypoint."""
7979
repo_root = pathlib.Path(__file__).resolve().parent.parent
8080
result = subprocess.run(
81-
[sys.executable, "-m", "weco.cli", "setup", "--help"],
82-
cwd=repo_root,
83-
capture_output=True,
84-
text=True,
85-
check=False,
81+
[sys.executable, "-m", "weco.cli", "setup", "--help"], cwd=repo_root, capture_output=True, text=True, check=False
8682
)
8783

8884
assert result.returncode == 0

tests/test_setup.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from rich.console import Console
88

99
from weco.cli import configure_setup_parser
10-
from weco.setup import handle_setup_command, prompt_tool_selection
11-
from weco.setup_targets import ALL_SETUP_OPTION_NAME, SETUP_TARGET_NAMES
10+
from weco.commands.setup import handle_setup_command, prompt_tool_selection
11+
from weco.commands.setup.targets import ALL_SETUP_OPTION_NAME, SETUP_TARGET_NAMES
1212

1313

1414
def build_setup_parser() -> argparse.ArgumentParser:
@@ -44,7 +44,7 @@ def fake_ask(*_args, **kwargs):
4444
captured["default"] = kwargs.get("default")
4545
return kwargs["default"]
4646

47-
monkeypatch.setattr("weco.setup.Prompt.ask", fake_ask)
47+
monkeypatch.setattr("weco.commands.setup.Prompt.ask", fake_ask)
4848

4949
selected = prompt_tool_selection(console=build_console())
5050

@@ -59,7 +59,7 @@ def test_handle_setup_command_all_runs_all_handlers(monkeypatch):
5959
def fake_run_setup(tool, console, local_path, ctx):
6060
called_tools.append((tool, local_path, ctx))
6161

62-
monkeypatch.setattr("weco.setup._run_setup_for_tool", fake_run_setup)
62+
monkeypatch.setattr("weco.commands.setup.run_setup_for_tool", fake_run_setup)
6363
args = argparse.Namespace(tool=ALL_SETUP_OPTION_NAME, local=None)
6464

6565
handle_setup_command(args, console=build_console())
@@ -75,7 +75,7 @@ def test_handle_setup_command_runs_single_selected_handler(monkeypatch, tool):
7575
def fake_run_setup(selected_tool, console, local_path, ctx):
7676
called_tools.append((selected_tool, local_path, ctx))
7777

78-
monkeypatch.setattr("weco.setup._run_setup_for_tool", fake_run_setup)
78+
monkeypatch.setattr("weco.commands.setup.run_setup_for_tool", fake_run_setup)
7979
args = argparse.Namespace(tool=tool, local=None)
8080

8181
handle_setup_command(args, console=build_console())

weco/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .env import WecoEnv
1111
from .events import send_event, get_event_context, CLIInvokedEvent, RunStartAttemptedEvent
1212
from .observe.cli import configure_observe_parser, execute_observe_command
13-
from .setup_targets import ALL_SETUP_OPTION_NAME, SETUP_TARGETS
13+
from .commands.setup.targets import ALL_SETUP_OPTION_NAME, SETUP_TARGETS
1414
from .utils import get_default_model, UnrecognizedAPIKeysError, DefaultModelNotFoundError
1515
from .validation import validate_sources, validate_log_directory, ValidationError, print_validation_error
1616

@@ -633,7 +633,7 @@ def _main() -> None:
633633
handle_share_command(run_id=args.run_id, output_mode=args.output, console=console)
634634
sys.exit(0)
635635
elif args.command == "setup":
636-
from .setup import handle_setup_command
636+
from .commands.setup import handle_setup_command
637637

638638
handle_setup_command(args, console)
639639
sys.exit(0)

weco/commands/setup/__init__.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Setup commands for integrating Weco with various AI tools."""
2+
3+
import pathlib
4+
import sys
5+
import tempfile
6+
import time
7+
8+
from rich.console import Console
9+
from rich.prompt import Prompt
10+
11+
from ...events import (
12+
create_event_context,
13+
send_event,
14+
SkillInstallCompletedEvent,
15+
SkillInstallFailedEvent,
16+
SkillInstallStartedEvent,
17+
)
18+
from ...utils import DownloadError
19+
from .install import SafetyError, SetupError, download_skill_archive, install_target
20+
from .targets import ALL_SETUP_OPTION_LABEL, ALL_SETUP_OPTION_NAME, SETUP_TARGET_BY_NAME, SETUP_TARGET_NAMES, SETUP_TARGETS
21+
22+
23+
class _SkillSource:
24+
"""Resolves the skill source directory on first use, downloading once if needed.
25+
26+
Use as a context manager so any downloaded tempdir is cleaned up on exit.
27+
The resolved path is reused across every target so ``weco setup all``
28+
downloads exactly once.
29+
"""
30+
31+
def __init__(self, local_path: pathlib.Path | None, console: Console):
32+
self._local_path = local_path
33+
self._console = console
34+
self._tmp_dir: tempfile.TemporaryDirectory | None = None
35+
self._downloaded_path: pathlib.Path | None = None
36+
37+
def __enter__(self) -> "_SkillSource":
38+
return self
39+
40+
def __exit__(self, *exc_info) -> None:
41+
if self._tmp_dir is not None:
42+
self._tmp_dir.cleanup()
43+
self._tmp_dir = None
44+
45+
@property
46+
def kind(self) -> str:
47+
return "local" if self._local_path else "download"
48+
49+
def path(self) -> pathlib.Path:
50+
if self._local_path is not None:
51+
return self._local_path
52+
if self._downloaded_path is None:
53+
self._tmp_dir = tempfile.TemporaryDirectory()
54+
dest = pathlib.Path(self._tmp_dir.name) / "skill"
55+
download_skill_archive(dest, self._console)
56+
self._downloaded_path = dest
57+
return self._downloaded_path
58+
59+
60+
def prompt_tool_selection(console: Console) -> list[str]:
61+
"""Prompt the user to select which tool(s) to set up."""
62+
tool_names = list(SETUP_TARGET_NAMES)
63+
all_option = len(tool_names) + 1
64+
65+
console.print("\n[bold cyan]Available tools to set up:[/]\n")
66+
for i, target in enumerate(SETUP_TARGETS, 1):
67+
console.print(f" {i}. {target.label} [dim]({target.name})[/]")
68+
console.print(f" {all_option}. {ALL_SETUP_OPTION_LABEL} [dim](default)[/]")
69+
70+
valid_choices = [str(i) for i in range(1, all_option + 1)]
71+
choice = Prompt.ask("\n[bold]Select an option[/]", choices=valid_choices, default=str(all_option), show_choices=True)
72+
73+
idx = int(choice)
74+
if idx == all_option:
75+
return tool_names
76+
return [tool_names[idx - 1]]
77+
78+
79+
def run_setup_for_tool(tool: str, console: Console, source: _SkillSource, ctx) -> None:
80+
"""Run setup for a single tool with event tracking and error handling."""
81+
send_event(SkillInstallStartedEvent(tool=tool, source=source.kind), ctx)
82+
start_time = time.time()
83+
84+
try:
85+
source_path = source.path()
86+
install_target(SETUP_TARGET_BY_NAME[tool], console, source_path)
87+
except DownloadError as e:
88+
send_event(SkillInstallFailedEvent(tool=tool, source=source.kind, error_type="download_error", stage="download"), ctx)
89+
console.print(f"\n[bold red]Error:[/] {e}")
90+
sys.exit(1)
91+
except SafetyError as e:
92+
send_event(SkillInstallFailedEvent(tool=tool, source=source.kind, error_type="safety_error", stage="setup"), ctx)
93+
console.print(f"\n[bold red]Safety Error:[/] {e}")
94+
sys.exit(1)
95+
except (SetupError, FileNotFoundError, OSError, ValueError) as e:
96+
send_event(SkillInstallFailedEvent(tool=tool, source=source.kind, error_type=type(e).__name__, stage="setup"), ctx)
97+
console.print(f"\n[bold red]Error:[/] {e}")
98+
sys.exit(1)
99+
100+
duration_ms = int((time.time() - start_time) * 1000)
101+
send_event(SkillInstallCompletedEvent(tool=tool, source=source.kind, duration_ms=duration_ms), ctx)
102+
103+
104+
def handle_setup_command(args, console: Console) -> None:
105+
"""Handle the ``weco setup`` command."""
106+
ctx = create_event_context()
107+
108+
if args.tool is None:
109+
selected_tools = prompt_tool_selection(console)
110+
elif args.tool == ALL_SETUP_OPTION_NAME:
111+
selected_tools = list(SETUP_TARGET_NAMES)
112+
elif args.tool in SETUP_TARGET_BY_NAME:
113+
selected_tools = [args.tool]
114+
else:
115+
available = ", ".join((*SETUP_TARGET_NAMES, ALL_SETUP_OPTION_NAME))
116+
console.print(f"[bold red]Error:[/] Unknown tool: {args.tool}")
117+
console.print(f"Available tools: {available}")
118+
sys.exit(1)
119+
120+
local_path = None
121+
if getattr(args, "local", None):
122+
local_path = pathlib.Path(args.local).expanduser().resolve()
123+
console.print(f"[bold cyan]Using local skill source:[/] {local_path}\n")
124+
125+
with _SkillSource(local_path, console) as source:
126+
for tool in selected_tools:
127+
run_setup_for_tool(tool, console, source, ctx)
128+
129+
console.print("\n[bold green]Setup complete.[/]")

weco/commands/setup/install.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Skill safety and installation primitives."""
2+
3+
import pathlib
4+
5+
from rich.console import Console
6+
7+
from ...utils import (
8+
DownloadError,
9+
UnsafeRemoveError,
10+
copy_directory,
11+
copy_file,
12+
download_github_archive,
13+
safe_remove_directory,
14+
)
15+
from .targets import SETUP_TARGETS, SetupTarget
16+
17+
18+
class SetupError(Exception):
19+
"""Base exception for setup failures."""
20+
21+
22+
class InvalidLocalRepoError(SetupError):
23+
"""Raised when a local path is not a valid skill repository."""
24+
25+
26+
class SafetyError(SetupError):
27+
"""Raised when a safety check fails during directory operations."""
28+
29+
30+
WECO_SKILL_REPO_URL = "https://github.com/WecoAI/weco-skill"
31+
WECO_SKILL_BRANCH = "main"
32+
33+
_COPY_IGNORE_PATTERNS = {".git", "__pycache__", ".DS_Store"}
34+
_ALLOWED_SKILL_PARENTS = {target.install_parent for target in SETUP_TARGETS}
35+
_SKILL_DIR_NAME = "weco"
36+
37+
38+
def _safe_remove_skill_dir(path: pathlib.Path) -> None:
39+
"""Remove an installed skill directory, enforcing skill-specific safety."""
40+
try:
41+
safe_remove_directory(path, allowed_parents=_ALLOWED_SKILL_PARENTS, expected_name=_SKILL_DIR_NAME)
42+
except UnsafeRemoveError as e:
43+
raise SafetyError(str(e)) from e
44+
45+
46+
def _validate_local_skill_repo(local_path: pathlib.Path) -> None:
47+
"""Validate that a local path is a valid weco-skill repository."""
48+
if not local_path.exists():
49+
raise InvalidLocalRepoError(f"Local path does not exist: {local_path}")
50+
if not local_path.is_dir():
51+
raise InvalidLocalRepoError(f"Local path is not a directory: {local_path}")
52+
if not (local_path / "SKILL.md").exists():
53+
raise InvalidLocalRepoError(
54+
f"Local path does not appear to be a weco-skill repository (expected SKILL.md at {local_path / 'SKILL.md'})"
55+
)
56+
57+
58+
def download_skill_archive(dest: pathlib.Path, console: Console) -> None:
59+
"""Download the Weco skill archive into ``dest`` and validate it looks like a skill repo."""
60+
dest.mkdir(parents=True, exist_ok=True)
61+
url = f"{WECO_SKILL_REPO_URL}/archive/refs/heads/{WECO_SKILL_BRANCH}.zip"
62+
console.print("[cyan]Downloading Weco skill...[/] ", end="")
63+
download_github_archive(url, dest)
64+
console.print("[green]done.[/]\n")
65+
if not (dest / "SKILL.md").exists():
66+
raise DownloadError("Downloaded content does not appear to be a valid weco-skill repository")
67+
68+
69+
def install_target(target: SetupTarget, console: Console, source_path: pathlib.Path) -> None:
70+
"""Install the Weco skill for a single target by copying from ``source_path``."""
71+
_validate_local_skill_repo(source_path)
72+
73+
target.install_dir.parent.mkdir(parents=True, exist_ok=True)
74+
if target.install_dir.exists():
75+
_safe_remove_skill_dir(target.install_dir)
76+
77+
copy_directory(source_path, target.install_dir, ignore_patterns=_COPY_IGNORE_PATTERNS)
78+
79+
for src_rel, dst_rel in target.extra_files:
80+
copy_file(target.install_dir / src_rel, target.install_dir / dst_rel)
81+
82+
console.print(f"Installing Weco for {target.label}... [green]done[/] [dim]({_shorten(target.install_dir)})[/]")
83+
84+
85+
def _shorten(path: pathlib.Path) -> str:
86+
"""Return a ``~``-abbreviated path string, or the absolute path if not under home."""
87+
home = pathlib.Path.home()
88+
try:
89+
return f"~/{path.relative_to(home)}"
90+
except ValueError:
91+
return str(path)
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Shared setup target definitions for AI tool integrations."""
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
import pathlib
55

66

@@ -12,6 +12,8 @@ class SetupTarget:
1212
label: str
1313
help_text: str
1414
install_dir: pathlib.Path
15+
# Extra files to copy after install, as (src, dst) pairs relative to install_dir.
16+
extra_files: tuple[tuple[str, str], ...] = field(default_factory=tuple)
1517

1618
@property
1719
def install_parent(self) -> pathlib.Path:
@@ -25,6 +27,7 @@ def install_parent(self) -> pathlib.Path:
2527
label="Claude Code",
2628
help_text="Set up Weco skill for Claude Code",
2729
install_dir=pathlib.Path.home() / ".claude" / "skills" / "weco",
30+
extra_files=(("snippets/claude.md", "CLAUDE.md"),),
2831
),
2932
SetupTarget(
3033
name="cursor",

weco/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from . import __pkg_version__, __base_url__, __dashboard_url__
2626
from .config import load_weco_api_key
2727
from .events import EventContext, create_event_context, set_event_context
28-
from .setup_targets import SETUP_TARGETS
28+
from .commands.setup.targets import SETUP_TARGETS
2929

3030

3131
_UNSET = object()

0 commit comments

Comments
 (0)