Coverage for little_loops / cli / create_extension.py: 29%
63 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""ll-create-extension: Scaffold a new little-loops extension project."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
9from little_loops.cli.output import configure_output, use_color_enabled
10from little_loops.cli_args import add_dry_run_arg
11from little_loops.logger import Logger
12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
15def _to_pkg_name(name: str) -> str:
16 """Convert kebab-case name to snake_case package name."""
17 return name.replace("-", "_")
20def _to_class_name(name: str) -> str:
21 """Convert kebab-case name to PascalCase class name."""
22 return "".join(part.capitalize() for part in name.replace("-", "_").split("_") if part)
25def _get_cwd() -> Path:
26 """Return current working directory."""
27 return Path.cwd()
30def _target_exists(target: Path) -> bool:
31 """Return True if target directory already exists."""
32 return target.exists()
35def _write_scaffold(target: Path, files: dict[Path, str]) -> None:
36 """Write scaffolded files to disk, creating parent directories as needed."""
37 for path, content in files.items():
38 path.parent.mkdir(parents=True, exist_ok=True)
39 path.write_text(content, encoding="utf-8")
42def _render_pyproject(name: str, pkg_name: str, class_name: str) -> str:
43 """Render pyproject.toml content for the scaffolded extension."""
44 parts: list[str] = [
45 "[build-system]",
46 'requires = ["hatchling"]',
47 'build-backend = "hatchling.build"',
48 "",
49 "[project]",
50 f'name = "{name}"',
51 'version = "0.1.0"',
52 'description = "A little-loops extension"',
53 'requires-python = ">=3.11"',
54 'dependencies = ["little-loops"]',
55 "",
56 '[project.entry-points."little_loops.extensions"]',
57 f'{name} = "{pkg_name}.extension:{class_name}"',
58 ]
59 return "\n".join(parts) + "\n"
62def _render_init(name: str) -> str:
63 """Render __init__.py content for the scaffolded extension package."""
64 parts: list[str] = [
65 f'"""{name} — a little-loops extension."""',
66 ]
67 return "\n".join(parts) + "\n"
70def _render_extension(name: str, class_name: str) -> str:
71 """Render extension.py skeleton for the scaffolded extension."""
72 parts: list[str] = [
73 f'"""{name} — a little-loops extension."""',
74 "",
75 "from __future__ import annotations",
76 "",
77 "from little_loops import LLEvent",
78 "",
79 "",
80 f"class {class_name}:",
81 f' """{class_name} extension.',
82 "",
83 " Implement on_event to handle little-loops lifecycle events.",
84 " Optional mixin Protocols (InterceptorExtension, ActionProviderExtension,",
85 " EvaluatorProviderExtension, LLHookIntentExtension) are opt-in — implement",
86 " their methods to activate.",
87 ' """',
88 "",
89 " def on_event(self, event: LLEvent) -> None:",
90 ' """Handle an incoming event."""',
91 " # See docs/reference/EVENT-SCHEMA.md for all available event types and payload fields",
92 " pass",
93 ]
94 return "\n".join(parts) + "\n"
97def _render_test(class_name: str, pkg_name: str) -> str:
98 """Render test_extension.py content using LLTestBus for the scaffolded extension."""
99 parts: list[str] = [
100 f'"""Tests for {class_name}."""',
101 "",
102 "from __future__ import annotations",
103 "",
104 "from little_loops import LLTestBus",
105 "",
106 f"from {pkg_name}.extension import {class_name}",
107 "",
108 "",
109 f"class Test{class_name}:",
110 " def test_receives_events(self) -> None:",
111 ' """Extension receives events via LLTestBus replay."""',
112 " bus = LLTestBus([])",
113 f" ext = {class_name}()",
114 " bus.register(ext)",
115 " bus.replay()",
116 " assert bus.delivered_events == []",
117 ]
118 return "\n".join(parts) + "\n"
121def main_create_extension() -> int:
122 """Entry point for ll-create-extension command.
124 Scaffold a new little-loops extension project directory.
126 Returns:
127 Exit code (0 = success, 1 = error)
128 """
129 with cli_event_context(DEFAULT_DB_PATH, "ll-create-extension", sys.argv[1:]):
130 parser = argparse.ArgumentParser(
131 prog="ll-create-extension",
132 description="Scaffold a new little-loops extension project",
133 formatter_class=argparse.RawDescriptionHelpFormatter,
134 epilog="""
135Examples:
136 %(prog)s my-dashboard-ext # Create extension scaffold
137 %(prog)s my-dashboard-ext --dry-run # Preview without writing files
138""",
139 )
141 parser.add_argument(
142 "name",
143 help="Extension name in kebab-case (e.g. my-dashboard-ext)",
144 )
145 add_dry_run_arg(parser)
147 args = parser.parse_args()
148 name: str = args.name
149 dry_run: bool = args.dry_run
151 configure_output()
152 logger = Logger(use_color=use_color_enabled())
154 pkg_name = _to_pkg_name(name)
155 class_name = _to_class_name(name)
156 target = _get_cwd() / name
158 if _target_exists(target):
159 logger.error(
160 f"directory '{name}' already exists. Remove it or choose a different name."
161 )
162 return 1
164 files: dict[Path, str] = {
165 target / "pyproject.toml": _render_pyproject(name, pkg_name, class_name),
166 target / pkg_name / "__init__.py": _render_init(name),
167 target / pkg_name / "extension.py": _render_extension(name, class_name),
168 target / "tests" / "test_extension.py": _render_test(class_name, pkg_name),
169 }
171 if dry_run:
172 logger.info(f"[DRY RUN] Would create: {name}/")
173 for path in files:
174 logger.info(f" {path.relative_to(target)}")
175 return 0
177 _write_scaffold(target, files)
178 logger.success(f"Created: {name}/")
179 for path in files:
180 logger.info(f" {path.relative_to(target)}")
181 logger.info("\nNext steps:")
182 logger.info(f" cd {name}")
183 logger.info(" pip install -e .")
184 logger.info(" python -m pytest tests/")
186 return 0