scaffold-ca-python 0.1.3__py3-none-any.whl → 0.2.0__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.
Files changed (39) hide show
  1. scaffold_ca_python/commands/delete_module.py +6 -21
  2. scaffold_ca_python/commands/generate_driven_adapter.py +11 -6
  3. scaffold_ca_python/commands/generate_entry_point.py +298 -9
  4. scaffold_ca_python/commands/generate_helper.py +4 -4
  5. scaffold_ca_python/commands/generate_model.py +4 -4
  6. scaffold_ca_python/commands/generate_project.py +1 -1
  7. scaffold_ca_python/commands/generate_use_case.py +6 -4
  8. scaffold_ca_python/core/file_writer.py +27 -2
  9. scaffold_ca_python/core/mcp_tool_spec.py +325 -0
  10. scaffold_ca_python/core/module_builder.py +54 -4
  11. scaffold_ca_python/core/name_utils.py +14 -0
  12. scaffold_ca_python/core/pyproject_writer.py +84 -1
  13. scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +5 -3
  14. scaffold_ca_python/factory/driven_adapters/da_secrets.py +2 -2
  15. scaffold_ca_python/factory/entry_points/ep_agent.py +2 -2
  16. scaffold_ca_python/factory/entry_points/ep_mcp.py +7 -7
  17. scaffold_ca_python/factory/entry_points/ep_restapi.py +8 -8
  18. scaffold_ca_python/models/file_operation.py +19 -1
  19. scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +1 -1
  20. scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +5 -4
  21. scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +6 -3
  22. scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +2 -2
  23. scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +2 -2
  24. scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +1 -1
  25. scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +0 -2
  26. scaffold_ca_python/templates/entry_point/mcp/test_tool_stub.py.jinja2 +8 -0
  27. scaffold_ca_python/templates/entry_point/mcp/tool_stub.py.jinja2 +5 -0
  28. scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +2 -2
  29. scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +1 -1
  30. scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +3 -3
  31. scaffold_ca_python/templates/project/application/config/container.py.jinja2 +1 -1
  32. scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +1 -1
  33. scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +2 -2
  34. scaffold_ca_python/templates/project/pyproject_toml.jinja2 +15 -8
  35. {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/METADATA +18 -14
  36. {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/RECORD +39 -36
  37. {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/WHEEL +1 -1
  38. {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/entry_points.txt +0 -0
  39. {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,7 +10,7 @@ import typer
10
10
  from rich.console import Console
11
11
 
12
12
  from scaffold_ca_python.core.module_builder import ModuleBuilder
13
- from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
13
+ from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
14
14
  from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
15
15
  from scaffold_ca_python.factory import ModuleFactory
16
16
  from scaffold_ca_python.factory.simple.delete_module_factory import DeleteModuleFactory
@@ -24,7 +24,7 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
24
24
  }
25
25
 
26
26
  _DM_HELP = "Delete a previously generated module and its test mirror. Alias 'dm'."
27
- _DM_EPILOG = "Examples:\n\n * scaffold dm --name Order\n\n * scaffold delete-module --name MyAdapter\n\n"
27
+ _DM_EPILOG = "Examples:\n\n * scaffold dm --name order\n\n * scaffold delete-module --name my_adapter\n\n"
28
28
 
29
29
 
30
30
  # ---------------------------------------------------------------------------
@@ -32,7 +32,7 @@ _DM_EPILOG = "Examples:\n\n * scaffold dm --name Order\n\n * scaffold delete-m
32
32
  # ---------------------------------------------------------------------------
33
33
 
34
34
 
35
- def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Path | None]]:
35
+ def _find_module_targets(pkg: str, project_root: Path, snake: str) -> list[tuple[Path, Path | None]]:
36
36
  """Return a list of (target_path, test_path_or_None) for all matches of *snake*.
37
37
 
38
38
  Searches across:
@@ -42,7 +42,6 @@ def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Pat
42
42
  4. infrastructure/entry_points/<snake>/
43
43
  5. infrastructure/helpers/<snake>/
44
44
  """
45
- pkg = _get_python_package(project_root)
46
45
  src_root = project_root / "src" / pkg
47
46
  tests_root = resolve_tests_root(project_root)
48
47
 
@@ -77,20 +76,6 @@ def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Pat
77
76
  return candidates
78
77
 
79
78
 
80
- def _get_python_package(root: Path) -> str:
81
- pyproject = root / "pyproject.toml"
82
- with pyproject.open("rb") as fh:
83
- data = tomllib.load(fh)
84
- section = data.get("tool", {}).get("scaffold-ca-python", {})
85
- name = section.get("name", root.name)
86
- import re
87
-
88
- s = name.replace("-", "_")
89
- s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
90
- s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
91
- return s.lower()
92
-
93
-
94
79
  # ---------------------------------------------------------------------------
95
80
  # Implementation
96
81
  # ---------------------------------------------------------------------------
@@ -99,7 +84,7 @@ def _get_python_package(root: Path) -> str:
99
84
  def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
100
85
  # Validate name
101
86
  try:
102
- validate_name(name)
87
+ validate_module_name(name)
103
88
  except ScaffoldError as exc:
104
89
  console.print(f"[red]Error:[/red] {exc}")
105
90
  raise typer.Exit(code=1) from None
@@ -118,7 +103,7 @@ def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
118
103
 
119
104
  # Find targets
120
105
  snake = to_snake_case(name)
121
- targets = _find_module_targets(project_root, snake)
106
+ targets = _find_module_targets(project_ctx.python_package, project_root, snake)
122
107
 
123
108
  if not targets:
124
109
  console.print(
@@ -174,7 +159,7 @@ def register(app: typer.Typer) -> None:
174
159
  def delete_module(
175
160
  ctx: typer.Context,
176
161
  name: Annotated[
177
- str | None, typer.Option("--name", help="Module name (PascalCase).", rich_help_panel="Required")
162
+ str | None, typer.Option("--name", help="Module name (snake_case).", rich_help_panel="Required")
178
163
  ] = None,
179
164
  confirm: Annotated[
180
165
  bool,
@@ -8,10 +8,11 @@ from typing import Annotated
8
8
 
9
9
  import typer
10
10
  from rich.console import Console
11
+ from rich.markup import escape
11
12
  from rich.tree import Tree
12
13
 
13
14
  from scaffold_ca_python.core.module_builder import ModuleBuilder
14
- from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
15
+ from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
15
16
  from scaffold_ca_python.core.project_detector import find_project_root
16
17
  from scaffold_ca_python.factory import ModuleFactory
17
18
  from scaffold_ca_python.factory.driven_adapters.da_generic import DrivenAdapterGeneric
@@ -37,7 +38,7 @@ _GDA_EPILOG = (
37
38
  " ==================================================\n\n"
38
39
  "Examples:\n\n"
39
40
  " * scaffold gda --type rest-consumer\n\n"
40
- " * scaffold generate-driven-adapter --type generic --name MyAdapter\n\n"
41
+ " * scaffold generate-driven-adapter --type generic --name my_adapter\n\n"
41
42
  )
42
43
 
43
44
 
@@ -55,7 +56,7 @@ def _generate_driven_adapter_impl(type_: str, name: str | None, dry_run: bool) -
55
56
  # --- Validate name if provided ---
56
57
  if name:
57
58
  try:
58
- validate_name(name)
59
+ validate_module_name(name)
59
60
  except ScaffoldError as exc:
60
61
  console.print(f"[red]Error:[/red] {exc}")
61
62
  raise typer.Exit(code=1) from None
@@ -116,8 +117,12 @@ def _generate_driven_adapter_impl(type_: str, name: str | None, dry_run: bool) -
116
117
  for p in sorted(created):
117
118
  tree.add(str(p.relative_to(project_root)))
118
119
  console.print(tree)
119
- if builder._dependencies:
120
- console.print(f"[dim]Would add to [project.dependencies]: {', '.join(builder._dependencies)}[/dim]")
120
+ if builder.pending_dependencies:
121
+ target = escape("[project.dependencies]")
122
+ console.print(f"[dim]Would add to {target}: {', '.join(builder.pending_dependencies)}[/dim]")
123
+ for grp, pkgs in builder.pending_group_dependencies.items():
124
+ target = escape(f"[dependency-groups.{grp}]")
125
+ console.print(f"[dim]Would add to {target}: {', '.join(pkgs)}[/dim]")
121
126
  return
122
127
 
123
128
  console.print(f"[green]✓[/green] Driven adapter [bold]{subdir}[/bold] created. Created {len(created)} file(s).")
@@ -146,7 +151,7 @@ def register(app: typer.Typer) -> None:
146
151
  str | None,
147
152
  typer.Option(
148
153
  "--name",
149
- help="Adapter name (required for --type generic).",
154
+ help="Adapter name in snake_case, e.g. my_adapter (required for --type generic).",
150
155
  rich_help_panel="Options",
151
156
  ),
152
157
  ] = None,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
5
6
  import json
6
7
  import tomllib
7
8
  from pathlib import Path
@@ -10,11 +11,14 @@ from typing import Annotated
10
11
  import typer
11
12
  import yaml
12
13
  from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.syntax import Syntax
13
16
  from rich.tree import Tree
14
17
 
18
+ from scaffold_ca_python.core import mcp_tool_spec
15
19
  from scaffold_ca_python.core.module_builder import ModuleBuilder
16
- from scaffold_ca_python.core.name_utils import ScaffoldError
17
- from scaffold_ca_python.core.project_detector import find_project_root
20
+ from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case
21
+ from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
18
22
  from scaffold_ca_python.factory import ModuleFactory
19
23
  from scaffold_ca_python.factory.entry_points.ep_agent import EntryPointAgent
20
24
  from scaffold_ca_python.factory.entry_points.ep_generic import EntryPointGeneric
@@ -44,12 +48,11 @@ _GEP_EPILOG = (
44
48
  " * mcp MCP server entry point\n\n"
45
49
  " * generic Plain entry point\n\n"
46
50
  " ==================================================\n\n"
47
- "Notes: --enable-kafka and --enable-mcp-client are agent type only.\n\n"
48
- " ==================================================\n\n"
49
51
  "Examples:\n\n"
50
52
  " * scaffold gep --type restapi\n\n"
51
53
  " * scaffold gep --type agent --enable-kafka\n\n"
52
54
  " * scaffold gep --type mcp\n\n"
55
+ " * scaffold gep --type mcp --add-tool --name create_file --param path:str --returns str\n\n"
53
56
  " * scaffold generate-entry-point --type generic\n"
54
57
  )
55
58
 
@@ -59,10 +62,239 @@ def _load_project_context(project_root: Path) -> ProjectContext:
59
62
  pyproject_path = project_root / "pyproject.toml"
60
63
  with open(pyproject_path, "rb") as f:
61
64
  data = tomllib.load(f)
62
- project_name = data.get("project", {}).get("name", "project").replace("-", "_")
65
+ project_name = to_snake_case(data.get("project", {}).get("name", "project"))
63
66
  return ProjectContext(name=project_name)
64
67
 
65
68
 
69
+ def _add_tool_impl(
70
+ type_: str,
71
+ swagger: str | None,
72
+ enable_kafka: bool,
73
+ enable_mcp_client: bool,
74
+ with_resources: bool,
75
+ with_prompts: bool,
76
+ name: str | None,
77
+ param: list[str] | None,
78
+ returns: str,
79
+ description: str | None,
80
+ dry_run: bool,
81
+ ) -> None:
82
+ """Add a tool to an existing MCP entry point (T018-T021, T027-T030a).
83
+
84
+ Implements User Story 1 and 2: appends a tool stub to tools.py and a registration
85
+ test to test_tools.py in an existing MCP project, with comprehensive guard rails.
86
+ """
87
+ # T028: Flag/type incompatibility guards (FR-011, FR-012) - before project access
88
+ # Check creation-only flags
89
+ creation_flags = []
90
+ if swagger is not None:
91
+ creation_flags.append("--swagger")
92
+ if enable_kafka:
93
+ creation_flags.append("--enable-kafka")
94
+ if enable_mcp_client:
95
+ creation_flags.append("--enable-mcp-client")
96
+ if with_resources:
97
+ creation_flags.append("--with-resources")
98
+ if with_prompts:
99
+ creation_flags.append("--with-prompts")
100
+
101
+ if creation_flags:
102
+ flag = creation_flags[0]
103
+ console.print(f"[red]Error:[/red] --add-tool is incompatible with {flag}.")
104
+ raise typer.Exit(code=1)
105
+
106
+ # Check type compatibility (FR-012) - handled by T016(a) for --type omitted
107
+ # Here we check non-mcp types
108
+ if type_ != "mcp":
109
+ console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
110
+ raise typer.Exit(code=1)
111
+
112
+ # T018: Format-only validation (no filesystem access yet)
113
+ if name is None:
114
+ console.print("[red]Error:[/red] --name is required with --add-tool.")
115
+ raise typer.Exit(code=1)
116
+
117
+ try:
118
+ tool_spec = mcp_tool_spec.build_tool_spec(name, param, returns, description)
119
+ except (ValueError, ScaffoldError) as e:
120
+ console.print(f"[red]Error:[/red] {e}")
121
+ raise typer.Exit(code=1) from None
122
+
123
+ # T019: Resolve project root and check entry point exists
124
+ try:
125
+ project_root = find_project_root()
126
+ except ScaffoldError as e:
127
+ console.print(f"[red]Error:[/red] {e}")
128
+ raise typer.Exit(code=1) from None
129
+
130
+ project_ctx = _load_project_context(project_root)
131
+
132
+ tools_py_path = (
133
+ project_root
134
+ / "src"
135
+ / project_ctx.python_package
136
+ / "infrastructure"
137
+ / "entry_points"
138
+ / "mcp_server"
139
+ / "tools.py"
140
+ )
141
+ test_tools_py_path = (
142
+ resolve_tests_root(project_root) / "infrastructure" / "entry_points" / "mcp_server" / "test_tools.py"
143
+ )
144
+
145
+ if not tools_py_path.exists():
146
+ console.print(
147
+ "[red]Error:[/red] No MCP entry point found at infrastructure/entry_points/mcp_server/.\n"
148
+ "[dim]Hint:[/dim] Run [cyan]scaffold gep --type mcp[/cyan] first to create one."
149
+ )
150
+ raise typer.Exit(code=1)
151
+
152
+ # T020: Read files and check structure
153
+ try:
154
+ original_tools_py = tools_py_path.read_text(encoding="utf-8")
155
+ except OSError as e:
156
+ console.print(f"[red]Error:[/red] Cannot read {tools_py_path}: {e}")
157
+ raise typer.Exit(code=1) from None
158
+
159
+ try:
160
+ original_test_tools_py = test_tools_py_path.read_text(encoding="utf-8")
161
+ except OSError:
162
+ console.print(
163
+ "[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
164
+ "does not have\n"
165
+ "the structure --add-tool needs to append a registration test.\n"
166
+ "[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
167
+ "[cyan]scaffold gep --type mcp[/cyan]."
168
+ )
169
+ raise typer.Exit(code=1)
170
+
171
+ # Check bind_tools is last top-level statement
172
+ if not mcp_tool_spec.has_bind_tools_function(original_tools_py):
173
+ console.print(
174
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py was generated by an older "
175
+ "scaffold\n"
176
+ "version and does not support --add-tool.\n"
177
+ "[dim]Hint:[/dim] Make [cyan]async def bind_tools[/cyan] the last function in tools.py, or "
178
+ "regenerate the entry point\n"
179
+ "(back up any custom tools first) with [cyan]scaffold gep --type mcp[/cyan]."
180
+ )
181
+ raise typer.Exit(code=1)
182
+
183
+ # Check test file has required structure
184
+ if not mcp_tool_spec.has_test_registration_structure(original_test_tools_py):
185
+ console.print(
186
+ "[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
187
+ "does not have\n"
188
+ "the structure --add-tool needs to append a registration test.\n"
189
+ "[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
190
+ "[cyan]scaffold gep --type mcp[/cyan]."
191
+ )
192
+ raise typer.Exit(code=1)
193
+
194
+ # Check for duplicate tool names
195
+ existing_names = mcp_tool_spec.find_existing_tool_names(original_tools_py)
196
+ if tool_spec.name in existing_names:
197
+ if tool_spec.name == "bind_tools":
198
+ console.print(
199
+ f"[red]Error:[/red] '{tool_spec.name}' is reserved by the tool-binding function and "
200
+ "cannot be used as a tool name."
201
+ )
202
+ else:
203
+ console.print(f"[red]Error:[/red] A tool named '{tool_spec.name}' already exists in tools.py.")
204
+ raise typer.Exit(code=1)
205
+
206
+ # T021: Render templates and append
207
+ builder = ModuleBuilder(
208
+ project_root=project_root,
209
+ project_ctx=project_ctx,
210
+ dry_run=dry_run,
211
+ )
212
+
213
+ # Render tool stub (4-space-indented nested function)
214
+ tool_stub_content = builder.render(
215
+ "entry_point/mcp/tool_stub.py.jinja2",
216
+ {
217
+ "name": tool_spec.name,
218
+ "params": [{"name": p.name, "type": p.type} for p in tool_spec.params],
219
+ "returns": tool_spec.returns,
220
+ "docstring": tool_spec.docstring,
221
+ },
222
+ )
223
+
224
+ # Render test stub
225
+ test_stub_content = builder.render(
226
+ "entry_point/mcp/test_tool_stub.py.jinja2",
227
+ {"name": tool_spec.name},
228
+ )
229
+
230
+ # Safety check: ensure appended content will parse
231
+ try:
232
+ ast.parse(original_tools_py + tool_stub_content)
233
+ except SyntaxError:
234
+ console.print(
235
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py cannot accept the new tool stub.\n"
236
+ "[dim]Hint:[/dim] Regenerate the entry point with [cyan]scaffold gep --type mcp[/cyan]."
237
+ )
238
+ raise typer.Exit(code=1)
239
+
240
+ # T030a: Check writability before any writes (FR-019)
241
+ import os
242
+
243
+ if not os.access(tools_py_path, os.W_OK):
244
+ console.print(
245
+ f"[red]Error:[/red] Cannot write to {tools_py_path.relative_to(project_root)}.\n"
246
+ "[dim]Hint:[/dim] Check the file's permissions and re-run the command."
247
+ )
248
+ raise typer.Exit(code=1)
249
+
250
+ if not os.access(test_tools_py_path, os.W_OK):
251
+ console.print(
252
+ f"[red]Error:[/red] Cannot write to {test_tools_py_path.relative_to(project_root)}.\n"
253
+ "[dim]Hint:[/dim] Check the file's permissions and re-run the command."
254
+ )
255
+ raise typer.Exit(code=1)
256
+
257
+ # Queue append operations
258
+ builder.append_to_file(tools_py_path, tool_stub_content)
259
+ builder.append_to_file(test_tools_py_path, test_stub_content)
260
+
261
+ # T030a: Persist changes with restore-on-failure (FR-019)
262
+ try:
263
+ modified_paths = builder.persist()
264
+ except OSError as e:
265
+ # Restore original content
266
+ try:
267
+ tools_py_path.write_text(original_tools_py, encoding="utf-8")
268
+ test_tools_py_path.write_text(original_test_tools_py, encoding="utf-8")
269
+ except OSError:
270
+ pass # Best effort restore
271
+
272
+ # Determine which file failed
273
+ error_str = str(e).lower()
274
+ if "test_tools" in error_str or str(test_tools_py_path) in str(e):
275
+ failed_file = "test_tools.py"
276
+ else:
277
+ failed_file = "tools.py"
278
+
279
+ console.print(
280
+ f"[red]Error:[/red] Failed to write {failed_file} ({e}). "
281
+ "tools.py was restored; no changes were made.\n"
282
+ "[dim]Hint:[/dim] Fix the underlying problem (permissions, disk space) and re-run the command."
283
+ )
284
+ raise typer.Exit(code=1) from None
285
+
286
+ if dry_run:
287
+ console.print("\n[yellow]Dry run:[/yellow] Would modify:\n")
288
+ for path in modified_paths:
289
+ console.print(f" • {path.relative_to(project_root)}")
290
+ console.print("\n[yellow]Tool stub preview:[/yellow]")
291
+ console.print(Syntax(tool_stub_content, "python", theme="monokai", line_numbers=False))
292
+ console.print("\n[yellow]Test stub preview:[/yellow]")
293
+ console.print(Syntax(test_stub_content, "python", theme="monokai", line_numbers=False))
294
+ else:
295
+ console.print(f"[green]✓[/green] Added tool '{tool_spec.name}' to MCP entry point.")
296
+
297
+
66
298
  def _generate_entry_point_impl(
67
299
  type_: str,
68
300
  swagger: str | None,
@@ -70,8 +302,30 @@ def _generate_entry_point_impl(
70
302
  enable_mcp_client: bool,
71
303
  with_resources: bool,
72
304
  with_prompts: bool,
305
+ add_tool: bool,
306
+ name: str | None,
307
+ param: list[str] | None,
308
+ returns: str,
309
+ description: str | None,
73
310
  dry_run: bool,
74
311
  ) -> None:
312
+ # T016(b): Dispatch to --add-tool implementation if flag is set
313
+ if add_tool:
314
+ _add_tool_impl(
315
+ type_,
316
+ swagger,
317
+ enable_kafka,
318
+ enable_mcp_client,
319
+ with_resources,
320
+ with_prompts,
321
+ name,
322
+ param,
323
+ returns,
324
+ description,
325
+ dry_run,
326
+ )
327
+ return
328
+
75
329
  # --- Validate type ---
76
330
  if type_ not in _REGISTRY:
77
331
  console.print(f"[red]Error:[/red] Unknown type '{type_}'. Allowed: {', '.join(_REGISTRY.keys())}.")
@@ -190,9 +444,10 @@ def _generate_entry_point_impl(
190
444
  tree.add(str(p.relative_to(project_root)))
191
445
  console.print(tree)
192
446
 
193
- # Check if dependencies were collected by looking at builder internals
194
- if builder._dependencies:
195
- console.print(f"[dim]Would add to [project.dependencies]: {', '.join(builder._dependencies)}[/dim]")
447
+ # Report any runtime dependencies queued for injection
448
+ if builder.pending_dependencies:
449
+ target = escape("[project.dependencies]")
450
+ console.print(f"[dim]Would add to {target}: {', '.join(builder.pending_dependencies)}[/dim]")
196
451
 
197
452
  if type_ == "restapi":
198
453
  main_py = project_root / "src" / pkg / "main.py"
@@ -285,6 +540,24 @@ def register(app: typer.Typer) -> None:
285
540
  show_default=True,
286
541
  ),
287
542
  ] = False,
543
+ add_tool: Annotated[
544
+ bool,
545
+ typer.Option(
546
+ "--add-tool/--no-add-tool",
547
+ help="Add a tool to an existing MCP entry point (mcp only).",
548
+ rich_help_panel="Options",
549
+ show_default=True,
550
+ ),
551
+ ] = False,
552
+ name: Annotated[str | None, typer.Option("--name", help="Tool name (required with --add-tool).")] = None,
553
+ param: Annotated[
554
+ list[str] | None,
555
+ typer.Option("--param", help="Tool parameter in 'name:type' format (repeatable)."),
556
+ ] = None,
557
+ returns: Annotated[str, typer.Option("--returns", help="Tool return type.")] = "str",
558
+ description: Annotated[
559
+ str | None, typer.Option("--description", help="Tool description for docstring.")
560
+ ] = None,
288
561
  dry_run: Annotated[
289
562
  bool,
290
563
  typer.Option(
@@ -296,9 +569,25 @@ def register(app: typer.Typer) -> None:
296
569
  ] = False,
297
570
  ) -> None:
298
571
  """Scaffold an entry point inside infrastructure/entry_points/."""
572
+ # T016(a): Check --add-tool without --type before the help shortcut
573
+ if add_tool and type_ is None:
574
+ console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
575
+ raise typer.Exit(code=1)
576
+
299
577
  if type_ is None:
300
578
  typer.echo(ctx.get_help())
301
579
  raise typer.Exit(0)
302
580
  _generate_entry_point_impl(
303
- type_, swagger, enable_kafka, enable_mcp_client, with_resources, with_prompts, dry_run
581
+ type_,
582
+ swagger,
583
+ enable_kafka,
584
+ enable_mcp_client,
585
+ with_resources,
586
+ with_prompts,
587
+ add_tool,
588
+ name,
589
+ param,
590
+ returns,
591
+ description,
592
+ dry_run,
304
593
  )
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.tree import Tree
12
12
 
13
13
  from scaffold_ca_python.core.module_builder import ModuleBuilder
14
- from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
14
+ from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
15
15
  from scaffold_ca_python.core.project_detector import find_project_root
16
16
  from scaffold_ca_python.factory import ModuleFactory
17
17
  from scaffold_ca_python.factory.simple.helper_factory import HelperFactory
@@ -25,13 +25,13 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
25
25
  }
26
26
 
27
27
  _GH_HELP = "Scaffold a helper utility module and test stub. Alias 'gh'."
28
- _GH_EPILOG = "Example:\n\n * scaffold gh --name JsonParser\n\n * scaffold generate-helper --name JsonParser\n\n"
28
+ _GH_EPILOG = "Example:\n\n * scaffold gh --name json_parser\n\n * scaffold generate-helper --name json_parser\n\n"
29
29
 
30
30
 
31
31
  def _generate_helper_impl(name: str, dry_run: bool) -> None:
32
32
  # --- Validate name ---
33
33
  try:
34
- validate_name(name)
34
+ validate_module_name(name)
35
35
  except ScaffoldError as exc:
36
36
  console.print(f"[red]Error:[/red] {exc}")
37
37
  raise typer.Exit(code=1) from None
@@ -100,7 +100,7 @@ def register(app: typer.Typer) -> None:
100
100
  def generate_helper(
101
101
  ctx: typer.Context,
102
102
  name: Annotated[
103
- str | None, typer.Option("--name", help="Helper name (PascalCase).", rich_help_panel="Required")
103
+ str | None, typer.Option("--name", help="Helper name (snake_case).", rich_help_panel="Required")
104
104
  ] = None,
105
105
  dry_run: Annotated[
106
106
  bool,
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.tree import Tree
12
12
 
13
13
  from scaffold_ca_python.core.module_builder import ModuleBuilder
14
- from scaffold_ca_python.core.name_utils import ScaffoldError, validate_name
14
+ from scaffold_ca_python.core.name_utils import ScaffoldError, validate_module_name
15
15
  from scaffold_ca_python.core.project_detector import find_project_root
16
16
  from scaffold_ca_python.factory import ModuleFactory
17
17
  from scaffold_ca_python.factory.simple.model_factory import ModelFactory
@@ -24,13 +24,13 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
24
24
  "model": ModelFactory,
25
25
  }
26
26
  _GM_HELP = "Scaffold a Pydantic v2 domain model and test stub. Alias 'gm'."
27
- _GM_EPILOG = "Example:\n\n * scaffold gm --name Order\n\n * scaffold generate-model --name Order\n\n"
27
+ _GM_EPILOG = "Example:\n\n * scaffold gm --name order\n\n * scaffold generate-model --name order\n\n"
28
28
 
29
29
 
30
30
  def _generate_model_impl(name: str, dry_run: bool) -> None:
31
31
  # --- Validate name ---
32
32
  try:
33
- validate_name(name)
33
+ validate_module_name(name)
34
34
  except ScaffoldError as exc:
35
35
  console.print(f"[red]Error:[/red] {exc}")
36
36
  raise typer.Exit(code=1) from None
@@ -100,7 +100,7 @@ def register(app: typer.Typer) -> None:
100
100
  def generate_model(
101
101
  ctx: typer.Context,
102
102
  name: Annotated[
103
- str | None, typer.Option("--name", help="Model name (PascalCase).", rich_help_panel="Required")
103
+ str | None, typer.Option("--name", help="Model name (snake_case).", rich_help_panel="Required")
104
104
  ] = None,
105
105
  dry_run: Annotated[
106
106
  bool,
@@ -151,7 +151,7 @@ def register(app: typer.Typer) -> None:
151
151
  str,
152
152
  typer.Option(
153
153
  "--name",
154
- help="Project name (PascalCase or snake_case).",
154
+ help="Project name (kebab-case or snake_case).",
155
155
  rich_help_panel="Required",
156
156
  ),
157
157
  ] = None, # type: ignore[assignment]
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.tree import Tree
12
12
 
13
13
  from scaffold_ca_python.core.module_builder import ModuleBuilder
14
- from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
14
+ from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
15
15
  from scaffold_ca_python.core.project_detector import find_project_root
16
16
  from scaffold_ca_python.factory import ModuleFactory
17
17
  from scaffold_ca_python.factory.simple.use_case_factory import UseCaseFactory
@@ -24,13 +24,15 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
24
24
  "use_case": UseCaseFactory,
25
25
  }
26
26
  _GUC_HELP = "Scaffold a use case in domain/usecase/. Alias 'guc'."
27
- _GUC_EPILOG = "Example:\n\n * scaffold guc --name CreateOrder\n\n * scaffold generate-use-case --name CreateOrder\n\n"
27
+ _GUC_EPILOG = (
28
+ "Example:\n\n * scaffold guc --name create_order\n\n * scaffold generate-use-case --name create_order\n\n"
29
+ )
28
30
 
29
31
 
30
32
  def _generate_use_case_impl(name: str, dry_run: bool) -> None:
31
33
  # --- Validate name ---
32
34
  try:
33
- validate_name(name)
35
+ validate_module_name(name)
34
36
  except ScaffoldError as exc:
35
37
  console.print(f"[red]Error:[/red] {exc}")
36
38
  raise typer.Exit(code=1) from None
@@ -102,7 +104,7 @@ def register(app: typer.Typer) -> None:
102
104
  def generate_use_case(
103
105
  ctx: typer.Context,
104
106
  name: Annotated[
105
- str | None, typer.Option("--name", help="Use case name (PascalCase).", rich_help_panel="Required")
107
+ str | None, typer.Option("--name", help="Use case name (snake_case).", rich_help_panel="Required")
106
108
  ] = None,
107
109
  dry_run: Annotated[
108
110
  bool,
@@ -7,7 +7,7 @@ import tempfile
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
- from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, InsertAfter
10
+ from scaffold_ca_python.models.file_operation import AppendToFile, CreateFile, DeleteFile, FileOperation, InsertAfter
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  pass
@@ -60,7 +60,7 @@ class FileWriter:
60
60
  for op in operations:
61
61
  if isinstance(op, CreateFile):
62
62
  result.append(op.file.path)
63
- elif isinstance(op, (DeleteFile, InsertAfter)):
63
+ elif isinstance(op, (DeleteFile, InsertAfter, AppendToFile)):
64
64
  result.append(op.path)
65
65
  return result
66
66
 
@@ -110,6 +110,11 @@ class FileWriter:
110
110
  for op in inserts:
111
111
  self._apply_insert_after(op)
112
112
 
113
+ # Process append_to_file mutations in declaration order
114
+ appends: list[AppendToFile] = [op for op in operations if isinstance(op, AppendToFile)]
115
+ for op in appends:
116
+ self._apply_append_to_file(op)
117
+
113
118
  return committed
114
119
 
115
120
  @staticmethod
@@ -126,3 +131,23 @@ class FileWriter:
126
131
  op.path.write_text("".join(lines), encoding="utf-8")
127
132
  return
128
133
  raise ValueError(f"insert_after anchor {op.anchor!r} not found in {op.path}")
134
+
135
+ @staticmethod
136
+ def _apply_append_to_file(op: AppendToFile) -> None:
137
+ """Append *op.content* to the end of *op.path*, or create the file.
138
+
139
+ When the file exists, trailing blank lines are normalized to a single newline
140
+ before appending. When it doesn't exist, it's created with *op.content* as
141
+ initial contents (leading blank lines stripped).
142
+ """
143
+ if op.path.exists():
144
+ # File exists - normalize trailing newlines and append
145
+ original = op.path.read_text(encoding="utf-8")
146
+ # Strip all trailing newlines, then ensure exactly one
147
+ normalized = original.rstrip("\n") + "\n"
148
+ op.path.write_text(normalized + op.content, encoding="utf-8")
149
+ else:
150
+ # File doesn't exist - create it with content (strip leading blank lines)
151
+ op.path.parent.mkdir(parents=True, exist_ok=True)
152
+ content = op.content.lstrip("\n")
153
+ op.path.write_text(content, encoding="utf-8")