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.
- structkit/__init__.py +6 -0
- structkit/commands/__init__.py +17 -0
- structkit/commands/completion.py +65 -0
- structkit/commands/generate.py +397 -0
- structkit/commands/generate_schema.py +67 -0
- structkit/commands/import.py +63 -0
- structkit/commands/info.py +87 -0
- structkit/commands/init.py +52 -0
- structkit/commands/list.py +89 -0
- structkit/commands/mcp.py +100 -0
- structkit/commands/validate.py +129 -0
- structkit/completers.py +54 -0
- structkit/content_fetcher.py +249 -0
- structkit/contribs/README.md +271 -0
- structkit/contribs/ansible-playbook.yaml +38 -0
- structkit/contribs/chef-cookbook.yaml +51 -0
- structkit/contribs/ci-cd-pipelines.yaml +67 -0
- structkit/contribs/cloudformation-files.yaml +21 -0
- structkit/contribs/configs/chglog.yaml +31 -0
- structkit/contribs/configs/codeowners.yaml +3 -0
- structkit/contribs/configs/devcontainer.yaml +35 -0
- structkit/contribs/configs/editor-config.yaml +11 -0
- structkit/contribs/configs/eslint.yaml +30 -0
- structkit/contribs/configs/jshint.yaml +11 -0
- structkit/contribs/configs/kubectl.yaml +23 -0
- structkit/contribs/configs/prettier.yaml +19 -0
- structkit/contribs/docker-files.yaml +27 -0
- structkit/contribs/documentation-template.yaml +33 -0
- structkit/contribs/git-hooks.yaml +19 -0
- structkit/contribs/github/chatmodes/plan.yaml +18 -0
- structkit/contribs/github/instructions/generic.yaml +5 -0
- structkit/contribs/github/prompts/generic.yaml +4 -0
- structkit/contribs/github/prompts/react-form.yaml +17 -0
- structkit/contribs/github/prompts/security-api.yaml +8 -0
- structkit/contribs/github/prompts/struct.yaml +90 -0
- structkit/contribs/github/templates.yaml +91 -0
- structkit/contribs/github/workflows/codeql.yaml +88 -0
- structkit/contribs/github/workflows/execute-tf-workflow.yaml +39 -0
- structkit/contribs/github/workflows/labeler.yaml +77 -0
- structkit/contribs/github/workflows/pre-commit.yaml +27 -0
- structkit/contribs/github/workflows/release-drafter.yaml +77 -0
- structkit/contribs/github/workflows/run-struct.yaml +30 -0
- structkit/contribs/github/workflows/stale.yaml +16 -0
- structkit/contribs/helm-chart.yaml +160 -0
- structkit/contribs/kubernetes-manifests.yaml +103 -0
- structkit/contribs/project/custom-structures.yaml +24 -0
- structkit/contribs/project/generic.yaml +309 -0
- structkit/contribs/project/go.yaml +104 -0
- structkit/contribs/project/java.yaml +85 -0
- structkit/contribs/project/n8n.yaml +100 -0
- structkit/contribs/project/nodejs.yaml +101 -0
- structkit/contribs/project/python.yaml +136 -0
- structkit/contribs/project/ruby.yaml +130 -0
- structkit/contribs/project/rust.yaml +106 -0
- structkit/contribs/prompts/run-struct-trigger.yaml +18 -0
- structkit/contribs/terraform/apps/aws-accounts.yaml +21 -0
- structkit/contribs/terraform/apps/environments.yaml +41 -0
- structkit/contribs/terraform/apps/generic.yaml +41 -0
- structkit/contribs/terraform/apps/github-organization.yaml +40 -0
- structkit/contribs/terraform/apps/init.yaml +11 -0
- structkit/contribs/terraform/modules/generic.yaml +58 -0
- structkit/contribs/vagrant-files.yaml +21 -0
- structkit/file_item.py +182 -0
- structkit/filters.py +112 -0
- structkit/input_store.py +35 -0
- structkit/logging_config.py +36 -0
- structkit/main.py +85 -0
- structkit/mcp_server.py +347 -0
- structkit/model_wrapper.py +47 -0
- structkit/template_renderer.py +258 -0
- structkit/utils.py +36 -0
- structkit-3.0.0.dist-info/METADATA +182 -0
- structkit-3.0.0.dist-info/RECORD +77 -0
- structkit-3.0.0.dist-info/WHEEL +5 -0
- structkit-3.0.0.dist-info/entry_points.txt +2 -0
- structkit-3.0.0.dist-info/licenses/LICENSE +201 -0
- structkit-3.0.0.dist-info/top_level.txt +1 -0
structkit/mcp_server.py
ADDED
|
@@ -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__)), "..")
|