Skip to content

Commit 9b3e65c

Browse files
authored
Merge pull request #135 from WecoAI/dev
Merge Dev - Expand setup tools and bump version
2 parents b923324 + 8ac3e3c commit 9b3e65c

12 files changed

Lines changed: 514 additions & 475 deletions

File tree

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,17 +256,26 @@ All observe commands are fire-and-forget — they always exit 0, so they never c
256256
| Command | Description |
257257
|---------|-------------|
258258
| `weco setup claude-code` | Set up Weco skill for Claude Code |
259-
| `weco setup cursor` | Set up Weco rules for Cursor |
259+
| `weco setup cursor` | Set up Weco skill for Cursor |
260+
| `weco setup codex` | Set up Weco skill for Codex |
261+
| `weco setup openclaw` | Set up Weco skill for OpenClaw |
262+
| `weco setup all` | Set up Weco for all supported AI tools |
260263

261264
The `setup` command installs Weco skills for AI coding assistants:
262265

263266
```bash
267+
weco setup # Interactive picker, defaults to "All of the above"
264268
weco setup claude-code # For Claude Code
265269
weco setup cursor # For Cursor
270+
weco setup codex # For Codex
271+
weco setup openclaw # For OpenClaw
272+
weco setup all # For all supported tools
266273
```
267274

268-
- **Claude Code**: Downloads the Weco skill to `~/.claude/skills/weco/` and updates `~/.claude/CLAUDE.md`
269-
- **Cursor**: Downloads the Weco skill to `~/.cursor/skills/weco/` and creates `~/.cursor/rules/weco.mdc`
275+
- **Claude Code**: Downloads the Weco skill to `~/.claude/skills/weco/` and writes `CLAUDE.md` inside the installed skill
276+
- **Cursor**: Downloads the Weco skill to `~/.cursor/skills/weco/`
277+
- **Codex**: Downloads the Weco skill to `$CODEX_HOME/skills/weco/` (defaults to `~/.codex/skills/weco/`)
278+
- **OpenClaw**: Downloads the Weco skill to `~/.openclaw/skills/weco/`
270279

271280
### Model Selection
272281

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "weco"
88
authors = [{ name = "Weco AI Team", email = "contact@weco.ai" }]
99
description = "Documentation for `weco`, a CLI for using Weco AI's code optimizer."
1010
readme = "README.md"
11-
version = "0.3.25"
11+
version = "0.3.26"
1212
license = { file = "LICENSE" }
1313
requires-python = ">=3.9"
1414
dependencies = [

tests/test_cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Tests for CLI functions, particularly parse_api_keys."""
22

3+
import pathlib
4+
import subprocess
5+
import sys
6+
37
import pytest
48
from weco.cli import parse_api_keys
59

@@ -68,3 +72,17 @@ def test_parse_api_keys_mixed_case_provider(self):
6872
"""Test that mixed case providers are normalized correctly."""
6973
result = parse_api_keys(["OpenAI=sk-xxx", "ANTHROPIC=sk-ant-yyy"])
7074
assert result == {"openai": "sk-xxx", "anthropic": "sk-ant-yyy"}
75+
76+
77+
def test_module_execution_invokes_cli_help():
78+
"""Running the module directly should invoke the CLI entrypoint."""
79+
repo_root = pathlib.Path(__file__).resolve().parent.parent
80+
result = subprocess.run(
81+
[sys.executable, "-m", "weco.cli", "setup", "--help"], cwd=repo_root, capture_output=True, text=True, check=False
82+
)
83+
84+
assert result.returncode == 0
85+
assert "usage:" in result.stdout
86+
assert "claude-code" in result.stdout
87+
assert "codex" in result.stdout
88+
assert "openclaw" in result.stdout

tests/test_setup.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for setup parser and target selection."""
2+
3+
import argparse
4+
import io
5+
6+
import pytest
7+
from rich.console import Console
8+
9+
from weco.cli import configure_setup_parser
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
12+
13+
14+
def build_setup_parser() -> argparse.ArgumentParser:
15+
"""Create an isolated parser for setup command tests."""
16+
parser = argparse.ArgumentParser()
17+
subparsers = parser.add_subparsers(dest="command")
18+
setup_parser = subparsers.add_parser("setup")
19+
configure_setup_parser(setup_parser)
20+
return parser
21+
22+
23+
def build_console() -> Console:
24+
"""Create a quiet console for tests."""
25+
return Console(file=io.StringIO(), force_terminal=False, color_system=None)
26+
27+
28+
def test_setup_parser_accepts_all_supported_tools():
29+
"""The setup parser should expose each supported tool plus the all shortcut."""
30+
parser = build_setup_parser()
31+
32+
supported_tools = (*SETUP_TARGET_NAMES, ALL_SETUP_OPTION_NAME)
33+
for tool in supported_tools:
34+
args = parser.parse_args(["setup", tool])
35+
assert args.command == "setup"
36+
assert args.tool == tool
37+
38+
39+
def test_prompt_tool_selection_defaults_to_all(monkeypatch):
40+
"""Pressing enter at the prompt should select all setup targets."""
41+
captured = {}
42+
43+
def fake_ask(*_args, **kwargs):
44+
captured["default"] = kwargs.get("default")
45+
return kwargs["default"]
46+
47+
monkeypatch.setattr("weco.commands.setup.Prompt.ask", fake_ask)
48+
49+
selected = prompt_tool_selection(console=build_console())
50+
51+
assert captured["default"] == str(len(SETUP_TARGET_NAMES) + 1)
52+
assert selected == list(SETUP_TARGET_NAMES)
53+
54+
55+
def test_handle_setup_command_all_runs_all_handlers(monkeypatch):
56+
"""The all shortcut should invoke every supported setup handler."""
57+
called_tools = []
58+
59+
def fake_run_setup(tool, console, local_path, ctx):
60+
called_tools.append((tool, local_path, ctx))
61+
62+
monkeypatch.setattr("weco.commands.setup.run_setup_for_tool", fake_run_setup)
63+
args = argparse.Namespace(tool=ALL_SETUP_OPTION_NAME, local=None)
64+
65+
handle_setup_command(args, console=build_console())
66+
67+
assert [tool for tool, _, _ in called_tools] == list(SETUP_TARGET_NAMES)
68+
69+
70+
@pytest.mark.parametrize("tool", SETUP_TARGET_NAMES)
71+
def test_handle_setup_command_runs_single_selected_handler(monkeypatch, tool):
72+
"""Named setup targets should dispatch to a single handler."""
73+
called_tools = []
74+
75+
def fake_run_setup(selected_tool, console, local_path, ctx):
76+
called_tools.append((selected_tool, local_path, ctx))
77+
78+
monkeypatch.setattr("weco.commands.setup.run_setup_for_tool", fake_run_setup)
79+
args = argparse.Namespace(tool=tool, local=None)
80+
81+
handle_setup_command(args, console=build_console())
82+
83+
assert [selected_tool for selected_tool, _, _ in called_tools] == [tool]

weco/cli.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +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 .commands.setup.targets import ALL_SETUP_OPTION_NAME, SETUP_TARGETS
1314
from .utils import get_default_model, UnrecognizedAPIKeysError, DefaultModelNotFoundError
1415
from .validation import validate_sources, validate_log_directory, ValidationError, print_validation_error
1516

@@ -298,11 +299,12 @@ def configure_setup_parser(setup_parser: argparse.ArgumentParser) -> None:
298299
"""Configure the setup command parser and its subcommands."""
299300
setup_subparsers = setup_parser.add_subparsers(dest="tool", help="AI tool to set up")
300301

301-
claude_parser = setup_subparsers.add_parser("claude-code", help="Set up Weco skill for Claude Code")
302-
_add_setup_source_args(claude_parser)
302+
for target in SETUP_TARGETS:
303+
target_parser = setup_subparsers.add_parser(target.name, help=target.help_text)
304+
_add_setup_source_args(target_parser)
303305

304-
cursor_parser = setup_subparsers.add_parser("cursor", help="Set up Weco rules for Cursor")
305-
_add_setup_source_args(cursor_parser)
306+
all_parser = setup_subparsers.add_parser(ALL_SETUP_OPTION_NAME, help="Set up Weco for all supported AI tools")
307+
_add_setup_source_args(all_parser)
306308

307309

308310
def configure_resume_parser(resume_parser: argparse.ArgumentParser) -> None:
@@ -631,7 +633,7 @@ def _main() -> None:
631633
handle_share_command(run_id=args.run_id, output_mode=args.output, console=console)
632634
sys.exit(0)
633635
elif args.command == "setup":
634-
from .setup import handle_setup_command
636+
from .commands.setup import handle_setup_command
635637

636638
handle_setup_command(args, console)
637639
sys.exit(0)
@@ -643,3 +645,7 @@ def _main() -> None:
643645
# or if an invalid command is provided.
644646
parser.print_help() # Default action if no command given and not chatbot.
645647
sys.exit(1)
648+
649+
650+
if __name__ == "__main__":
651+
main()

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.[/]")

0 commit comments

Comments
 (0)