1"""ll-create-extension: Scaffold a new little-loops extension project."""

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

9 from little_loops.cli.output import configure_output, use_color_enabled

10 from little_loops.cli_args import add_dry_run_arg

11 from little_loops.logger import Logger

12 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

13

14

15 def _to_pkg_name(name: str) -> str:

16 """Convert kebab-case name to snake_case package name."""

17 return name.replace("-", "_")

18

19

20 def _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)

23

24

25 def _get_cwd() -> Path:

26 """Return current working directory."""

27 return Path.cwd()

28

29

30 def _target_exists(target: Path) -> bool:

31 """Return True if target directory already exists."""

32 return target.exists()

33

34

35 def _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")

40

41

42 def _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"

60

61

62 def _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"

68

69

70 def _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"

95

96

97 def _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"

119

120

121 def main_create_extension() -> int:

122 """Entry point for ll-create-extension command.

123

124 Scaffold a new little-loops extension project directory.

125

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="""

135 Examples:

136 %(prog)s my-dashboard-ext # Create extension scaffold

137 %(prog)s my-dashboard-ext --dry-run # Preview without writing files

138""",

139 )

140

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)

146

147 args = parser.parse_args()

148 name: str = args.name

149 dry_run: bool = args.dry_run

150

151 configure_output()

152 logger = Logger(use_color=use_color_enabled())

153

154 pkg_name = _to_pkg_name(name)

155 class_name = _to_class_name(name)

156 target = _get_cwd() / name

157

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

163

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 }

170

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

176

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/")

185

186 return 0