structkit 3.0.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 (77) hide show
  1. structkit/__init__.py +6 -0
  2. structkit/commands/__init__.py +17 -0
  3. structkit/commands/completion.py +65 -0
  4. structkit/commands/generate.py +397 -0
  5. structkit/commands/generate_schema.py +67 -0
  6. structkit/commands/import.py +63 -0
  7. structkit/commands/info.py +87 -0
  8. structkit/commands/init.py +52 -0
  9. structkit/commands/list.py +89 -0
  10. structkit/commands/mcp.py +100 -0
  11. structkit/commands/validate.py +129 -0
  12. structkit/completers.py +54 -0
  13. structkit/content_fetcher.py +249 -0
  14. structkit/contribs/README.md +271 -0
  15. structkit/contribs/ansible-playbook.yaml +38 -0
  16. structkit/contribs/chef-cookbook.yaml +51 -0
  17. structkit/contribs/ci-cd-pipelines.yaml +67 -0
  18. structkit/contribs/cloudformation-files.yaml +21 -0
  19. structkit/contribs/configs/chglog.yaml +31 -0
  20. structkit/contribs/configs/codeowners.yaml +3 -0
  21. structkit/contribs/configs/devcontainer.yaml +35 -0
  22. structkit/contribs/configs/editor-config.yaml +11 -0
  23. structkit/contribs/configs/eslint.yaml +30 -0
  24. structkit/contribs/configs/jshint.yaml +11 -0
  25. structkit/contribs/configs/kubectl.yaml +23 -0
  26. structkit/contribs/configs/prettier.yaml +19 -0
  27. structkit/contribs/docker-files.yaml +27 -0
  28. structkit/contribs/documentation-template.yaml +33 -0
  29. structkit/contribs/git-hooks.yaml +19 -0
  30. structkit/contribs/github/chatmodes/plan.yaml +18 -0
  31. structkit/contribs/github/instructions/generic.yaml +5 -0
  32. structkit/contribs/github/prompts/generic.yaml +4 -0
  33. structkit/contribs/github/prompts/react-form.yaml +17 -0
  34. structkit/contribs/github/prompts/security-api.yaml +8 -0
  35. structkit/contribs/github/prompts/struct.yaml +90 -0
  36. structkit/contribs/github/templates.yaml +91 -0
  37. structkit/contribs/github/workflows/codeql.yaml +88 -0
  38. structkit/contribs/github/workflows/execute-tf-workflow.yaml +39 -0
  39. structkit/contribs/github/workflows/labeler.yaml +77 -0
  40. structkit/contribs/github/workflows/pre-commit.yaml +27 -0
  41. structkit/contribs/github/workflows/release-drafter.yaml +77 -0
  42. structkit/contribs/github/workflows/run-struct.yaml +30 -0
  43. structkit/contribs/github/workflows/stale.yaml +16 -0
  44. structkit/contribs/helm-chart.yaml +160 -0
  45. structkit/contribs/kubernetes-manifests.yaml +103 -0
  46. structkit/contribs/project/custom-structures.yaml +24 -0
  47. structkit/contribs/project/generic.yaml +309 -0
  48. structkit/contribs/project/go.yaml +104 -0
  49. structkit/contribs/project/java.yaml +85 -0
  50. structkit/contribs/project/n8n.yaml +100 -0
  51. structkit/contribs/project/nodejs.yaml +101 -0
  52. structkit/contribs/project/python.yaml +136 -0
  53. structkit/contribs/project/ruby.yaml +130 -0
  54. structkit/contribs/project/rust.yaml +106 -0
  55. structkit/contribs/prompts/run-struct-trigger.yaml +18 -0
  56. structkit/contribs/terraform/apps/aws-accounts.yaml +21 -0
  57. structkit/contribs/terraform/apps/environments.yaml +41 -0
  58. structkit/contribs/terraform/apps/generic.yaml +41 -0
  59. structkit/contribs/terraform/apps/github-organization.yaml +40 -0
  60. structkit/contribs/terraform/apps/init.yaml +11 -0
  61. structkit/contribs/terraform/modules/generic.yaml +58 -0
  62. structkit/contribs/vagrant-files.yaml +21 -0
  63. structkit/file_item.py +182 -0
  64. structkit/filters.py +112 -0
  65. structkit/input_store.py +35 -0
  66. structkit/logging_config.py +36 -0
  67. structkit/main.py +85 -0
  68. structkit/mcp_server.py +347 -0
  69. structkit/model_wrapper.py +47 -0
  70. structkit/template_renderer.py +258 -0
  71. structkit/utils.py +36 -0
  72. structkit-3.0.0.dist-info/METADATA +182 -0
  73. structkit-3.0.0.dist-info/RECORD +77 -0
  74. structkit-3.0.0.dist-info/WHEEL +5 -0
  75. structkit-3.0.0.dist-info/entry_points.txt +2 -0
  76. structkit-3.0.0.dist-info/licenses/LICENSE +201 -0
  77. structkit-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,347 @@
1
+ """
2
+ MCP Server implementation for the structkit tool using FastMCP stdio transport.
3
+
4
+ This module provides MCP (Model Context Protocol) support for:
5
+ 1. Listing available structures
6
+ 2. Getting detailed information about structures
7
+ 3. Generating structures with various options
8
+ 4. Validating structure configurations
9
+ """
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ import sys
14
+ import yaml
15
+ from typing import Any, Dict, Optional
16
+
17
+ from fastmcp import FastMCP
18
+
19
+ from structkit.commands.generate import GenerateCommand
20
+ from structkit.commands.validate import ValidateCommand
21
+ from structkit import __version__
22
+
23
+
24
+ class StructMCPServer:
25
+ """FastMCP-based MCP Server for structkit tool operations."""
26
+
27
+ def __init__(self):
28
+ self.app = FastMCP("structkit-mcp-server", version=__version__)
29
+ self.logger = logging.getLogger(__name__)
30
+ self._register_tools()
31
+
32
+ # =====================
33
+ # Tool logic (transport-agnostic)
34
+ # =====================
35
+ def _list_structures_logic(self, structures_path: Optional[str] = None) -> str:
36
+ this_file = os.path.dirname(os.path.realpath(__file__))
37
+ contribs_path = os.path.join(this_file, "contribs")
38
+
39
+ paths_to_list = [contribs_path]
40
+ if structures_path:
41
+ paths_to_list = [structures_path, contribs_path]
42
+
43
+ all_structures = set()
44
+ for path in paths_to_list:
45
+ if os.path.exists(path):
46
+ for root, _, files in os.walk(path):
47
+ for file in files:
48
+ if file.endswith(".yaml"):
49
+ rel = os.path.relpath(os.path.join(root, file), path)[:-5]
50
+ if path != contribs_path:
51
+ rel = f"+ {rel}"
52
+ all_structures.add(rel)
53
+
54
+ sorted_list = sorted(all_structures)
55
+ result_text = "📃 Available structures:\n\n" + "\n".join([f" - {s}" for s in sorted_list])
56
+ result_text += "\n\nNote: Structures with '+' sign are custom structures"
57
+ return result_text
58
+
59
+ def _get_structure_info_logic(self, structure_name: Optional[str], structures_path: Optional[str] = None) -> str:
60
+ if not structure_name:
61
+ return "Error: structure_name is required"
62
+
63
+ # Resolve path
64
+ if structure_name.startswith("file://") and structure_name.endswith(".yaml"):
65
+ file_path = structure_name[7:]
66
+ else:
67
+ this_file = os.path.dirname(os.path.realpath(__file__))
68
+ base = structures_path or os.path.join(this_file, "contribs")
69
+ file_path = os.path.join(base, f"{structure_name}.yaml")
70
+
71
+ if not os.path.exists(file_path):
72
+ return f"❗ Structure not found: {file_path}"
73
+
74
+ with open(file_path, "r") as f:
75
+ config = yaml.safe_load(f) or {}
76
+
77
+ result_lines = [
78
+ "📒 Structure definition\n",
79
+ f" 📌 Name: {structure_name}\n",
80
+ f" 📌 Description: {config.get('description', 'No description')}\n",
81
+ ]
82
+
83
+ files = config.get("files", [])
84
+ if files:
85
+ result_lines.append(" 📌 Files:\n")
86
+ for item in files:
87
+ for name in item.keys():
88
+ result_lines.append(f" - {name}\n")
89
+
90
+ folders = config.get("folders", [])
91
+ if folders:
92
+ result_lines.append(" 📌 Folders:\n")
93
+ for item in folders:
94
+ if isinstance(item, dict):
95
+ for folder, content in item.items():
96
+ result_lines.append(f" - {folder}\n")
97
+ if isinstance(content, dict):
98
+ # Support both 'struct' (config key) and 'structkit' (package name)
99
+ structs = content.get("struct") or content.get("structkit")
100
+ if isinstance(structs, list):
101
+ result_lines.append(" • struct(s):\n")
102
+ for s in structs:
103
+ result_lines.append(f" - {s}\n")
104
+ elif isinstance(structs, str):
105
+ result_lines.append(f" • struct: {structs}\n")
106
+ if isinstance(content.get("with"), dict):
107
+ with_items = " ".join([f"{k}={v}" for k, v in content["with"].items()])
108
+ result_lines.append(f" • with:{with_items}\n")
109
+ else:
110
+ result_lines.append(f" - {item}\n")
111
+
112
+ return "".join(result_lines)
113
+
114
+ def _generate_structure_logic(
115
+ self,
116
+ structure_definition: str,
117
+ base_path: str,
118
+ output: str = "files",
119
+ dry_run: bool = False,
120
+ mappings: Optional[Dict[str, str]] = None,
121
+ structures_path: Optional[str] = None,
122
+ ) -> str:
123
+ class Args:
124
+ pass
125
+ args = Args()
126
+ args.structure_definition = structure_definition
127
+ args.base_path = base_path
128
+ args.output = "console" if output == "console" else "file"
129
+ args.dry_run = dry_run
130
+ args.structures_path = structures_path
131
+ args.vars = None
132
+ args.mappings_file = None
133
+ args.backup = None
134
+ args.file_strategy = "overwrite"
135
+ args.global_system_prompt = None
136
+ args.non_interactive = True
137
+ args.input_store = "/tmp/structkit/input.json"
138
+ args.diff = False
139
+ args.log = "INFO"
140
+ args.config_file = None
141
+ args.log_file = None
142
+
143
+ # If mappings provided, convert to vars string consumed by GenerateCommand
144
+ if mappings:
145
+ args.vars = ",".join([f"{k}={v}" for k, v in mappings.items()])
146
+
147
+ if output == "console":
148
+ from io import StringIO
149
+ buf = StringIO()
150
+ old = sys.stdout
151
+ sys.stdout = buf
152
+ try:
153
+ # Create a dummy parser for GenerateCommand
154
+ import argparse
155
+ dummy_parser = argparse.ArgumentParser()
156
+ GenerateCommand(dummy_parser).execute(args)
157
+ text = buf.getvalue()
158
+ return text.strip() or "Structure generation completed successfully"
159
+ finally:
160
+ sys.stdout = old
161
+ else:
162
+ # Create a dummy parser for GenerateCommand
163
+ import argparse
164
+ dummy_parser = argparse.ArgumentParser()
165
+ GenerateCommand(dummy_parser).execute(args)
166
+ if dry_run:
167
+ return f"Dry run completed for structure '{structure_definition}' at '{base_path}'"
168
+ return f"Structure '{structure_definition}' generated successfully at '{base_path}'"
169
+
170
+ def _validate_structure_logic(self, yaml_file: Optional[str]) -> str:
171
+ if not yaml_file:
172
+ return "Error: yaml_file is required"
173
+
174
+ class Args:
175
+ pass
176
+ args = Args()
177
+ args.yaml_file = yaml_file
178
+ args.log = "INFO"
179
+ args.config_file = None
180
+ args.log_file = None
181
+
182
+ from io import StringIO
183
+ buf = StringIO()
184
+ old = sys.stdout
185
+ sys.stdout = buf
186
+ try:
187
+ # Create a dummy parser for ValidateCommand
188
+ import argparse
189
+ dummy_parser = argparse.ArgumentParser()
190
+ ValidateCommand(dummy_parser).execute(args)
191
+ text = buf.getvalue()
192
+ return text.strip() or f"✅ YAML file '{yaml_file}' is valid"
193
+ finally:
194
+ sys.stdout = old
195
+
196
+ # =====================
197
+ # FastMCP tool registration (maps to logic above)
198
+ # =====================
199
+ def _register_tools(self):
200
+ @self.app.tool(name="list_structures", description="List all available structure definitions")
201
+ async def list_structures(structures_path: Optional[str] = None) -> str:
202
+ self.logger.debug(f"MCP request: list_structures args={{'structures_path': {structures_path!r}}}")
203
+ result = self._list_structures_logic(structures_path)
204
+ preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
205
+ self.logger.debug(f"MCP response: list_structures len={len(result)} preview=\n{preview}")
206
+ return result
207
+
208
+ @self.app.tool(name="get_structure_info", description="Get detailed information about a specific structure")
209
+ async def get_structure_info(structure_name: str, structures_path: Optional[str] = None) -> str:
210
+ self.logger.debug(
211
+ f"MCP request: get_structure_info args={{'structure_name': {structure_name!r}, 'structures_path': {structures_path!r}}}"
212
+ )
213
+ result = self._get_structure_info_logic(structure_name, structures_path)
214
+ preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
215
+ self.logger.debug(f"MCP response: get_structure_info len={len(result)} preview=\n{preview}")
216
+ return result
217
+
218
+ @self.app.tool(name="generate_structure", description="Generate a project structure using specified definition and options")
219
+ async def generate_structure(
220
+ structure_definition: str,
221
+ base_path: str,
222
+ output: str = "files",
223
+ dry_run: bool = False,
224
+ mappings: Optional[Dict[str, str]] = None,
225
+ structures_path: Optional[str] = None,
226
+ ) -> str:
227
+ self.logger.debug(
228
+ "MCP request: generate_structure args=%s",
229
+ {
230
+ "structure_definition": structure_definition,
231
+ "base_path": base_path,
232
+ "output": output,
233
+ "dry_run": dry_run,
234
+ "mappings": mappings,
235
+ "structures_path": structures_path,
236
+ },
237
+ )
238
+ result = self._generate_structure_logic(
239
+ structure_definition,
240
+ base_path,
241
+ output,
242
+ dry_run,
243
+ mappings,
244
+ structures_path,
245
+ )
246
+ preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
247
+ self.logger.debug(f"MCP response: generate_structure len={len(result)} preview=\n{preview}")
248
+ return result
249
+
250
+ @self.app.tool(name="validate_structure", description="Validate a structure configuration YAML file")
251
+ async def validate_structure(yaml_file: str) -> str:
252
+ self.logger.debug(f"MCP request: validate_structure args={{'yaml_file': {yaml_file!r}}}")
253
+ result = self._validate_structure_logic(yaml_file)
254
+ preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
255
+ self.logger.debug(f"MCP response: validate_structure len={len(result)} preview=\n{preview}")
256
+ return result
257
+
258
+ async def run(
259
+ self,
260
+ transport: str = "stdio",
261
+ *,
262
+ show_banner: bool = True,
263
+ host: str | None = None,
264
+ port: int | None = None,
265
+ path: str | None = None,
266
+ log_level: str | None = None,
267
+ stateless_http: bool | None = None,
268
+ fastmcp_log_level: str | None = None,
269
+ ):
270
+ """Run the FastMCP server with the specified transport.
271
+
272
+ Note: FastMCP.run(...) is synchronous in fastmcp>=2.x, so we
273
+ offload it to a thread to avoid blocking the event loop.
274
+
275
+ Args:
276
+ transport: "stdio" | "http" | "sse"
277
+ show_banner: Whether to print the FastMCP banner
278
+ host: Host to bind for HTTP/SSE transports
279
+ port: Port to bind for HTTP/SSE transports
280
+ path: Endpoint path for HTTP/SSE transports
281
+ log_level: Log level for the HTTP server (uvicorn)
282
+ stateless_http: Whether to use stateless HTTP mode (HTTP only)
283
+ fastmcp_log_level: Log level for FastMCP internals (e.g., DEBUG, INFO)
284
+ """
285
+ loop = asyncio.get_running_loop()
286
+ def _run():
287
+ # Apply FastMCP-specific logger level if provided
288
+ if fastmcp_log_level:
289
+ try:
290
+ logging.getLogger('fastmcp').setLevel(getattr(logging, fastmcp_log_level.upper()))
291
+ except Exception:
292
+ logging.getLogger('fastmcp').setLevel(logging.DEBUG if str(fastmcp_log_level).upper() == 'DEBUG' else logging.INFO)
293
+ kwargs = {"show_banner": show_banner}
294
+ if transport in {"http", "sse"}:
295
+ if host is not None:
296
+ kwargs["host"] = host
297
+ if port is not None:
298
+ kwargs["port"] = port
299
+ if path is not None:
300
+ kwargs["path"] = path
301
+ if log_level is not None:
302
+ kwargs["log_level"] = log_level
303
+ if stateless_http is not None and transport == "http":
304
+ kwargs["stateless_http"] = stateless_http
305
+ logging.getLogger(__name__).info(
306
+ "Starting FastMCP %s server on http://%s:%s%s (uvicorn log_level=%s)",
307
+ transport,
308
+ kwargs.get("host", "127.0.0.1"),
309
+ kwargs.get("port", 8000),
310
+ kwargs.get("path", "/mcp"),
311
+ kwargs.get("log_level", None),
312
+ )
313
+ else:
314
+ logging.getLogger(__name__).info("Starting FastMCP stdio server")
315
+ self.app.run(transport, **kwargs)
316
+ await loop.run_in_executor(None, _run)
317
+
318
+ # =====================
319
+ # Compatibility methods for testing (simulates MCP result structure)
320
+ # =====================
321
+ async def _handle_get_structure_info(self, params: Dict[str, Any]):
322
+ """Compatibility method for tests that expect MCP-style responses."""
323
+ structure_name = params.get('structure_name')
324
+ structures_path = params.get('structures_path')
325
+
326
+ result_text = self._get_structure_info_logic(structure_name, structures_path)
327
+
328
+ # Mock MCP response structure
329
+ class MockContent:
330
+ def __init__(self, text):
331
+ self.text = text
332
+
333
+ class MockResult:
334
+ def __init__(self, content):
335
+ self.content = content
336
+
337
+ return MockResult([MockContent(result_text)])
338
+
339
+
340
+ async def main():
341
+ logging.basicConfig(level=logging.INFO)
342
+ server = StructMCPServer()
343
+ await server.run()
344
+
345
+
346
+ if __name__ == "__main__":
347
+ asyncio.run(main())
@@ -0,0 +1,47 @@
1
+ import os
2
+ import logging
3
+ from dotenv import load_dotenv
4
+ from pydantic_ai import Agent
5
+
6
+ load_dotenv()
7
+
8
+ class ModelWrapper:
9
+ """
10
+ Wraps model logic using pydantic-ai Agent, allowing use of multiple LLM providers.
11
+ """
12
+ def __init__(self, logger=None):
13
+ self.logger = logger or logging.getLogger(__name__)
14
+ self.model_name = os.getenv("AI_MODEL") or "openai:gpt-4.1"
15
+
16
+ # Set default API key if not provided to prevent startup crashes
17
+ if self.model_name.startswith("openai:") and not os.getenv("OPENAI_API_KEY"):
18
+ os.environ["OPENAI_API_KEY"] = "sk-default-placeholder-key"
19
+ self.logger.warning("OPENAI_API_KEY not set. Using placeholder. AI features may not work properly.")
20
+
21
+ self.agent = Agent(model=self.model_name)
22
+ self.logger.debug(f"Configured Agent with model: {self.model_name}")
23
+
24
+ def generate_content(self, system_prompt, user_prompt, dry_run=False):
25
+ if not self.agent:
26
+ self.logger.warning("No agent configured. Skipping content generation.")
27
+ return "No agent configured. Skipping content generation."
28
+ if dry_run:
29
+ self.logger.info("[DRY RUN] Would generate content using AI agent.")
30
+ return "[DRY RUN] Generating content using AI agent"
31
+
32
+ # Check if using placeholder API key
33
+ if os.getenv("OPENAI_API_KEY") == "sk-default-placeholder-key":
34
+ self.logger.warning("Using placeholder API key. Set OPENAI_API_KEY environment variable for AI features to work.")
35
+ return "AI generation skipped: Please set OPENAI_API_KEY environment variable."
36
+
37
+ prompt = f"{user_prompt}"
38
+ try:
39
+ self.agent.system_prompt = system_prompt
40
+ result = self.agent.run_sync(prompt)
41
+ return result.output
42
+ except Exception as e:
43
+ self.logger.error(f"AI agent generation failed: {e}")
44
+ # Provide more helpful error message for API key issues
45
+ if "api_key" in str(e).lower() or "unauthorized" in str(e).lower():
46
+ return "AI generation failed: Please check your OPENAI_API_KEY environment variable."
47
+ return f"AI agent generation failed: {e}"
@@ -0,0 +1,258 @@
1
+ # FILE: template_renderer.py
2
+ import logging
3
+ import os
4
+ import sys
5
+ from jinja2 import Environment, meta
6
+ from structkit.filters import (
7
+ get_latest_release,
8
+ slugify,
9
+ get_default_branch,
10
+ gen_uuid,
11
+ now_iso,
12
+ env as env_get,
13
+ read_file,
14
+ to_yaml,
15
+ from_yaml,
16
+ to_json,
17
+ from_json,
18
+ )
19
+ from structkit.input_store import InputStore
20
+ from structkit.utils import get_current_repo
21
+
22
+ class TemplateRenderer:
23
+ def __init__(self, config_variables, input_store, non_interactive, mappings=None):
24
+ self.config_variables = config_variables
25
+ self.non_interactive = non_interactive
26
+ self.mappings = mappings or {}
27
+
28
+ self.env = Environment(
29
+ trim_blocks=True,
30
+ block_start_string='{%@',
31
+ block_end_string='@%}',
32
+ variable_start_string='{{@',
33
+ variable_end_string='@}}',
34
+ comment_start_string='{#@',
35
+ comment_end_string='@#}'
36
+ )
37
+
38
+ self.logger = logging.getLogger(__name__)
39
+
40
+ custom_filters = {
41
+ 'latest_release': get_latest_release,
42
+ 'slugify': slugify,
43
+ 'default_branch': get_default_branch,
44
+ 'to_yaml': to_yaml,
45
+ 'from_yaml': from_yaml,
46
+ 'to_json': to_json,
47
+ 'from_json': from_json,
48
+ }
49
+
50
+ globals = {
51
+ 'current_repo': get_current_repo,
52
+ 'uuid': gen_uuid,
53
+ 'now': now_iso,
54
+ 'env': env_get,
55
+ 'read_file': read_file,
56
+ }
57
+
58
+ self.env.globals.update(globals)
59
+ self.env.filters.update(custom_filters)
60
+ self.input_store = InputStore(input_store)
61
+ self.input_store.load()
62
+ self.input_data = self.input_store.get_data()
63
+
64
+ # Get the config variables from the list and create a dictionary that has
65
+ # variable name and their default value
66
+ #
67
+ # Example:
68
+ # variables:
69
+ # - session_name:
70
+ # type: string
71
+ # default: my_session
72
+ # - project_name:
73
+ # type: string
74
+ # default: my_project
75
+ # help: The name of the project
76
+ #
77
+ # Returns:
78
+ # {'session_name': 'my_session', 'project_name': 'my_project'}
79
+ def get_defaults_from_config(self):
80
+ self.logger.debug(f"Config variables: {self.config_variables}")
81
+ defaults = {}
82
+ for item in self.config_variables:
83
+ for name, content in item.items():
84
+ # Explicit default value
85
+ if 'default' in content:
86
+ defaults[name] = content.get('default')
87
+ # Default from environment variable (env or default_from_env)
88
+ env_key = content.get('env') or content.get('default_from_env')
89
+ if env_key and os.environ.get(env_key) is not None:
90
+ defaults[name] = os.environ.get(env_key)
91
+ return defaults
92
+
93
+
94
+ def render_template(self, content, vars):
95
+ # Inject mappings into the template context
96
+ if self.mappings:
97
+ vars = vars.copy() if vars else {}
98
+ vars['mappings'] = self.mappings
99
+ template = self.env.from_string(content)
100
+ return template.render(vars)
101
+
102
+ def _get_variable_icon(self, var_name, var_type):
103
+ """Get contextual icon for variable based on name and type"""
104
+ var_lower = var_name.lower()
105
+
106
+ # Project/name related
107
+ if any(keyword in var_lower for keyword in ['project', 'name', 'app', 'title']):
108
+ return '🚀'
109
+ # Environment related
110
+ elif any(keyword in var_lower for keyword in ['env', 'environment', 'stage', 'deploy']):
111
+ return '🌍'
112
+ # Database related (check before URL to prioritize database_url)
113
+ elif any(keyword in var_lower for keyword in ['db', 'database', 'sql']):
114
+ return '🗄️'
115
+ # Port/network related
116
+ elif any(keyword in var_lower for keyword in ['port', 'url', 'host', 'endpoint']):
117
+ return '🔌'
118
+ # Boolean/toggle related
119
+ elif var_type == 'boolean' or any(keyword in var_lower for keyword in ['enable', 'disable', 'toggle', 'flag']):
120
+ return '⚡'
121
+ # Authentication/security
122
+ elif any(keyword in var_lower for keyword in ['token', 'key', 'secret', 'password', 'auth']):
123
+ return '🔐'
124
+ # Version/tag related
125
+ elif any(keyword in var_lower for keyword in ['version', 'tag', 'release']):
126
+ return '🏷️'
127
+ # Path/directory related
128
+ elif any(keyword in var_lower for keyword in ['path', 'dir', 'folder']):
129
+ return '📁'
130
+ # Default
131
+ else:
132
+ return '🔧'
133
+
134
+ def prompt_for_missing_vars(self, content, vars):
135
+ parsed_content = self.env.parse(content)
136
+ undeclared_variables = meta.find_undeclared_variables(parsed_content)
137
+ self.logger.debug(f"Undeclared variables: {undeclared_variables}")
138
+
139
+ # Build schema lookup
140
+ schema = {}
141
+ for item in (self.config_variables or []):
142
+ for name, conf in item.items():
143
+ schema[name] = conf or {}
144
+
145
+ # Prompt the user for any missing variables
146
+ # Suggest a default from the config if available
147
+ default_values = self.get_defaults_from_config()
148
+ self.logger.debug(f"Default values from config: {default_values}")
149
+
150
+ for var in undeclared_variables:
151
+ if var not in vars:
152
+ conf = schema.get(var, {})
153
+ required = conf.get('required', False)
154
+ default = self.input_data.get(var, default_values.get(var, ""))
155
+ if self.non_interactive:
156
+ if required and (default is None or default == ""):
157
+ raise ValueError(f"Missing required variable '{var}' in non-interactive mode")
158
+ user_input = default
159
+ else:
160
+ # Interactive prompt with enum support (choose by value or index)
161
+ enum = conf.get('enum')
162
+ var_type = conf.get('type', 'string')
163
+
164
+ # Get description if available (support both 'description' and 'help' fields)
165
+ description = conf.get('description') or conf.get('help')
166
+
167
+ # Get contextual icon
168
+ icon = self._get_variable_icon(var, var_type)
169
+
170
+ # ANSI color codes for formatting
171
+ BOLD = '\033[1m'
172
+ RESET = '\033[0m'
173
+
174
+ if enum:
175
+ # Build options list string like "(1) dev, (2) staging, (3) prod"
176
+ options = ", ".join([f"({i+1}) {val}" for i, val in enumerate(enum)])
177
+
178
+ if description:
179
+ print(f"{icon} {BOLD}{var}{RESET}: {description}")
180
+ print(f" Options: {options}")
181
+ raw = input(f" Enter value [{default}]: ") or default
182
+ else:
183
+ raw = input(f"{icon} {BOLD}{var}{RESET} [{default}] {options}: ") or default
184
+
185
+ raw = raw.strip()
186
+ if raw == "":
187
+ user_input = default
188
+ elif raw.isdigit() and 1 <= int(raw) <= len(enum):
189
+ user_input = enum[int(raw) - 1]
190
+ elif raw in enum:
191
+ user_input = raw
192
+ else:
193
+ # For invalid enum input, raise immediately instead of re-prompting
194
+ raise ValueError(f"Variable '{var}' must be one of {enum}, got: {raw}")
195
+ else:
196
+ if description:
197
+ print(f"{icon} {BOLD}{var}{RESET}: {description}")
198
+ user_input = input(f" Enter value [{default}]: ") or default
199
+ else:
200
+ user_input = input(f"{icon} {BOLD}{var}{RESET} [{default}]: ") or default
201
+ # Coerce and validate according to schema
202
+ coerced = self._coerce_and_validate(var, user_input, conf)
203
+ self.input_store.set_value(var, coerced)
204
+ vars[var] = coerced
205
+ self.input_store.save()
206
+ return vars
207
+
208
+ def _coerce_and_validate(self, name, value, conf):
209
+ # Type coercion
210
+ vtype = (conf.get('type') or 'string').lower()
211
+ original = value
212
+ try:
213
+ if vtype == 'boolean' or vtype == 'bool':
214
+ if isinstance(value, bool):
215
+ coerced = value
216
+ elif isinstance(value, str):
217
+ coerced = value.strip().lower() in ['1', 'true', 'yes', 'y', 'on']
218
+ else:
219
+ coerced = bool(value)
220
+ elif vtype == 'number' or vtype == 'float':
221
+ coerced = float(value) if value != '' and value is not None else None
222
+ elif vtype == 'integer' or vtype == 'int':
223
+ coerced = int(value) if value not in (None, '') else None
224
+ else:
225
+ coerced = '' if value is None else str(value)
226
+ except Exception:
227
+ raise ValueError(f"Variable '{name}' could not be coerced to {vtype} (value: {original})")
228
+
229
+ # Enum validation
230
+ enum = conf.get('enum')
231
+ if enum is not None and coerced not in enum:
232
+ raise ValueError(f"Variable '{name}' must be one of {enum}, got: {coerced}")
233
+
234
+ # Regex validation (only for strings)
235
+ pattern = conf.get('regex') or conf.get('pattern')
236
+ if pattern and isinstance(coerced, str):
237
+ import re as _re
238
+ if _re.fullmatch(pattern, coerced) is None:
239
+ raise ValueError(f"Variable '{name}' does not match required pattern: {pattern}")
240
+
241
+ # Min/Max validation
242
+ def _as_num(x):
243
+ try:
244
+ return float(x)
245
+ except Exception:
246
+ return None
247
+ minv = conf.get('min')
248
+ maxv = conf.get('max')
249
+ if minv is not None:
250
+ cv = _as_num(coerced)
251
+ if cv is not None and cv < float(minv):
252
+ raise ValueError(f"Variable '{name}' must be >= {minv}, got {coerced}")
253
+ if maxv is not None:
254
+ cv = _as_num(coerced)
255
+ if cv is not None and cv > float(maxv):
256
+ raise ValueError(f"Variable '{name}' must be <= {maxv}, got {coerced}")
257
+
258
+ return coerced
structkit/utils.py ADDED
@@ -0,0 +1,36 @@
1
+ import yaml
2
+ import os
3
+ import subprocess
4
+
5
+ def read_config_file(file_path):
6
+ with open(file_path, 'r') as f:
7
+ return yaml.safe_load(f)
8
+
9
+ def merge_configs(file_config, args):
10
+ args_dict = vars(args)
11
+ for key, value in file_config.items():
12
+ if key in args_dict and args_dict[key] is None:
13
+ args_dict[key] = value
14
+ return args_dict
15
+
16
+ def get_current_repo():
17
+ try:
18
+ # Get the remote URL
19
+ remote_url = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url'], text=True).strip()
20
+
21
+ # Handle different remote URL formats (HTTPS and SSH)
22
+ if remote_url.startswith("https://"):
23
+ # Extract "owner/repository" from HTTPS URL
24
+ owner_repo = remote_url.split("github.com/")[1].replace(".git", "")
25
+ elif remote_url.startswith("git@"):
26
+ # Extract "owner/repository" from SSH URL
27
+ owner_repo = remote_url.split(":")[1].replace(".git", "")
28
+ else:
29
+ return "Error: Not a GitHub repository"
30
+
31
+ return owner_repo
32
+ except subprocess.CalledProcessError:
33
+ return "Error: Not a Git repository or no remote URL set"
34
+
35
+
36
+ project_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")