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
structkit/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version('struct')
5
+ except PackageNotFoundError:
6
+ __version__ = "unknown"
@@ -0,0 +1,17 @@
1
+ import logging
2
+ from structkit.completers import log_level_completer
3
+
4
+ # Base command class
5
+ class Command:
6
+ def __init__(self, parser):
7
+ self.parser = parser
8
+ self.logger = logging.getLogger(__name__)
9
+ self.add_common_arguments()
10
+
11
+ def add_common_arguments(self):
12
+ self.parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)').completer = log_level_completer
13
+ self.parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file')
14
+ self.parser.add_argument('-i', '--log-file', type=str, help='Path to a log file')
15
+
16
+ def execute(self, args):
17
+ raise NotImplementedError("Subclasses should implement this!")
@@ -0,0 +1,65 @@
1
+ from structkit.commands import Command
2
+ import os
3
+
4
+ SUPPORTED_SHELLS = ["bash", "zsh", "fish"]
5
+
6
+ class CompletionCommand(Command):
7
+ def __init__(self, parser):
8
+ super().__init__(parser)
9
+ parser.description = "Manage CLI shell completions for structkit (shtab-generated)"
10
+ sub = parser.add_subparsers(dest="action")
11
+
12
+ install = sub.add_parser("install", help="Print the commands to enable completion for your shell")
13
+ install.add_argument("shell", nargs="?", choices=SUPPORTED_SHELLS, help="Shell type (auto-detected if omitted)")
14
+ install.set_defaults(func=self._install)
15
+
16
+ def _detect_shell(self):
17
+ shell = os.environ.get("SHELL", "")
18
+ if shell:
19
+ basename = os.path.basename(shell)
20
+ if basename in SUPPORTED_SHELLS:
21
+ return basename
22
+ # Fallback to zsh if running zsh, else bash
23
+ if os.environ.get("ZSH_NAME") or os.environ.get("ZDOTDIR"):
24
+ return "zsh"
25
+ return "bash"
26
+
27
+ def _install(self, args):
28
+ shell = args.shell or self._detect_shell()
29
+ print(f"Detected shell: {shell}")
30
+
31
+ if shell == "bash":
32
+ print("\n# Install shtab (once, in your environment):")
33
+ print("python -m pip install shtab")
34
+ print("\n# Generate static bash completion for 'struct':")
35
+ print("mkdir -p ~/.local/share/bash-completion/completions")
36
+ print("structkit --print-completion bash > ~/.local/share/bash-completion/completions/structkit")
37
+ print("\n# Apply now (or open a new shell):")
38
+ print("source ~/.bashrc")
39
+
40
+ elif shell == "zsh":
41
+ print("\n# Install shtab (once, in your environment):")
42
+ print("python -m pip install shtab")
43
+ print("\n# Generate static zsh completion for 'struct':")
44
+ print("mkdir -p ~/.zfunc")
45
+ print("structkit --print-completion zsh > ~/.zfunc/_structkit")
46
+ print("\n# Ensure zsh loads user functions/completions (append to ~/.zshrc if needed):")
47
+ print('echo "fpath=(~/.zfunc $fpath)" >> ~/.zshrc')
48
+ print('echo "autoload -U compinit && compinit" >> ~/.zshrc')
49
+ print("\n# Apply now (or open a new shell):")
50
+ print("exec zsh")
51
+
52
+ elif shell == "fish":
53
+ print("\n# Install shtab (once, in your environment):")
54
+ print("python -m pip install shtab")
55
+ print("\n# Generate static fish completion for 'struct':")
56
+ print('mkdir -p ~/.config/fish/completions')
57
+ print('structkit --print-completion fish > ~/.config/fish/completions/structkit.fish')
58
+ print("\n# Apply now:")
59
+ print("fish -c 'source ~/.config/fish/completions/structkit.fish'")
60
+
61
+ else:
62
+ self.logger.error(f"Unsupported shell: {shell}. Supported: {', '.join(SUPPORTED_SHELLS)}")
63
+ return
64
+
65
+ print("\nTip: You can also print completion directly via: structkit --print-completion <shell>")
@@ -0,0 +1,397 @@
1
+ from structkit.commands import Command
2
+ import os
3
+ import yaml
4
+ import argparse
5
+ from structkit.file_item import FileItem
6
+ from structkit.completers import file_strategy_completer, structures_completer
7
+ from structkit.template_renderer import TemplateRenderer
8
+
9
+ import subprocess
10
+
11
+ # Generate command class
12
+ class GenerateCommand(Command):
13
+ def __init__(self, parser):
14
+ super().__init__(parser)
15
+ parser.description = "Generate the project structure from a YAML configuration file"
16
+ structure_arg = parser.add_argument('structure_definition', nargs='?', default='.struct.yaml', type=str, help='Path to the YAML configuration file (default: .struct.yaml)')
17
+ structure_arg.completer = structures_completer
18
+ parser.add_argument('base_path', nargs='?', default='.', type=str, help='Base path where the structure will be created (default: current directory)')
19
+ parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions')
20
+ parser.add_argument('-n', '--input-store', type=str, help='Path to the input store', default='/tmp/structkit/input.json')
21
+ parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories')
22
+ parser.add_argument('--diff', action='store_true', help='Show unified diffs for files that would change during dry-run or console output')
23
+ parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2')
24
+ parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder')
25
+ parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files').completer = file_strategy_completer
26
+ parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI')
27
+ parser.add_argument('--non-interactive', action='store_true', help='Run the command in non-interactive mode')
28
+ parser.add_argument('--mappings-file', type=str, action='append',
29
+ help='Path to a YAML file containing mappings to be used in templates (can be specified multiple times)')
30
+ parser.add_argument('-o', '--output', type=str,
31
+ choices=['console', 'file'], default='file', help='Output mode')
32
+ parser.set_defaults(func=self.execute)
33
+
34
+ def _parse_template_vars(self, vars_str):
35
+ """Parse a comma-separated KEY=VALUE string into a dict safely.
36
+ - Ignores empty tokens and trailing commas
37
+ - Supports values containing '=' by splitting only on the first '='
38
+ - Logs and skips malformed entries without raising
39
+ """
40
+ result = {}
41
+ if not vars_str:
42
+ return result
43
+ # Normalize by removing accidental leading/trailing commas and whitespace
44
+ tokens = [t.strip() for t in vars_str.strip(', ').split(',')]
45
+ for token in tokens:
46
+ if not token:
47
+ continue
48
+ if '=' not in token:
49
+ # Skip malformed item but warn
50
+ self.logger.warning(f"Skipping malformed template var (no '='): '{token}'")
51
+ continue
52
+ key, value = token.split('=', 1)
53
+ key = key.strip()
54
+ value = value
55
+ if not key:
56
+ self.logger.warning(f"Skipping template var with empty key: '{token}'")
57
+ continue
58
+ result[key] = value
59
+ return result
60
+
61
+ def _deep_merge_dicts(self, dict1, dict2):
62
+ """
63
+ Deep merge two dictionaries, with dict2 values overriding dict1 values.
64
+ """
65
+ result = dict1.copy()
66
+ for key, value in dict2.items():
67
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
68
+ result[key] = self._deep_merge_dicts(result[key], value)
69
+ else:
70
+ result[key] = value
71
+ return result
72
+
73
+ def _run_hooks(self, hooks, hook_type="pre"): # helper for running hooks
74
+ if not hooks:
75
+ return True
76
+ for cmd in hooks:
77
+ self.logger.info(f"Running {hook_type}-hook: {cmd}")
78
+ try:
79
+ result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
80
+ if result.stdout:
81
+ self.logger.info(f"{hook_type}-hook stdout: {result.stdout.strip()}")
82
+ if result.stderr:
83
+ self.logger.info(f"{hook_type}-hook stderr: {result.stderr.strip()}")
84
+ except subprocess.CalledProcessError as e:
85
+ self.logger.error(f"{hook_type}-hook failed: {cmd}")
86
+ self.logger.error(f"Return code: {e.returncode}")
87
+ if e.stdout:
88
+ self.logger.error(f"stdout: {e.stdout.strip()}")
89
+ if e.stderr:
90
+ self.logger.error(f"stderr: {e.stderr.strip()}")
91
+ return False
92
+ return True
93
+
94
+ def _load_yaml_config(self, structure_definition, structures_path):
95
+ if structure_definition.endswith(".yaml") and not structure_definition.startswith("file://"):
96
+ structure_definition = f"file://{structure_definition}"
97
+
98
+ if structure_definition.startswith("file://") and structure_definition.endswith(".yaml"):
99
+ with open(structure_definition[7:], 'r') as f:
100
+ return yaml.safe_load(f)
101
+ else:
102
+ this_file = os.path.dirname(os.path.realpath(__file__))
103
+ contribs_path = os.path.join(this_file, "..", "contribs")
104
+ file_path = os.path.join(contribs_path, f"{structure_definition}.yaml")
105
+ if structures_path:
106
+ file_path = os.path.join(structures_path, f"{structure_definition}.yaml")
107
+ if not os.path.exists(file_path):
108
+ file_path = os.path.join(contribs_path, f"{structure_definition}.yaml")
109
+ if not os.path.exists(file_path):
110
+ self.logger.error(f"❗ File not found: {file_path}")
111
+ return None
112
+ with open(file_path, 'r') as f:
113
+ return yaml.safe_load(f)
114
+
115
+ def execute(self, args):
116
+ self.logger.info(f"Generating structure")
117
+ self.logger.info(f" Structure definition: {args.structure_definition}")
118
+ self.logger.info(f" Base path: {args.base_path}")
119
+
120
+ # Load mappings if provided
121
+ mappings = {}
122
+ if getattr(args, 'mappings_file', None):
123
+ for mappings_file_path in args.mappings_file:
124
+ if os.path.exists(mappings_file_path):
125
+ self.logger.info(f"Loading mappings from: {mappings_file_path}")
126
+ with open(mappings_file_path, 'r') as mf:
127
+ try:
128
+ file_mappings = yaml.safe_load(mf) or {}
129
+ # Deep merge the mappings, with later files overriding earlier ones
130
+ mappings = self._deep_merge_dicts(mappings, file_mappings)
131
+ except Exception as e:
132
+ self.logger.error(f"Failed to load mappings file {mappings_file_path}: {e}")
133
+ return
134
+ else:
135
+ self.logger.error(f"Mappings file not found: {mappings_file_path}")
136
+ return
137
+
138
+ if args.backup and not os.path.exists(args.backup):
139
+ os.makedirs(args.backup)
140
+
141
+ if args.base_path and not os.path.exists(args.base_path) and "console" not in args.output:
142
+ self.logger.info(f"Creating base path: {args.base_path}")
143
+ os.makedirs(args.base_path)
144
+
145
+ # Load config to check for hooks
146
+ config = None
147
+ config = self._load_yaml_config(args.structure_definition, args.structures_path)
148
+ if config is None:
149
+ return
150
+
151
+ pre_hooks = config.get('pre_hooks', [])
152
+ post_hooks = config.get('post_hooks', [])
153
+
154
+ # Run pre-hooks
155
+ if not self._run_hooks(pre_hooks, hook_type="pre"):
156
+ self.logger.error("Aborting generation due to pre-hook failure.")
157
+ return
158
+
159
+ # Actually generate structure
160
+ self._create_structure(args, mappings)
161
+
162
+ # Run post-hooks
163
+ if not self._run_hooks(post_hooks, hook_type="post"):
164
+ self.logger.error("Post-hook failed.")
165
+ return
166
+
167
+ def _create_structure(self, args, mappings=None, summary=None, print_summary=True):
168
+ if isinstance(args, dict):
169
+ args = argparse.Namespace(**args)
170
+ this_file = os.path.dirname(os.path.realpath(__file__))
171
+ contribs_path = os.path.join(this_file, "..", "contribs")
172
+
173
+ config = self._load_yaml_config(args.structure_definition, args.structures_path)
174
+ if config is None:
175
+ return summary if summary is not None else None
176
+
177
+ # Safely parse template variables
178
+ template_vars = self._parse_template_vars(args.vars) if getattr(args, 'vars', None) else {}
179
+ config_structure = config.get('files', config.get('structure', []))
180
+ config_folders = config.get('folders', [])
181
+ config_variables = config.get('variables', [])
182
+
183
+ # Action counters for final summary (initialize once and reuse across recursive calls)
184
+ if summary is None:
185
+ summary = {
186
+ "created": 0,
187
+ "updated": 0,
188
+ "appended": 0,
189
+ "skipped": 0,
190
+ "backed_up": 0,
191
+ "renamed": 0,
192
+ "folders": 0,
193
+ "dry_run_created": 0,
194
+ "dry_run_updated": 0,
195
+ }
196
+
197
+ for item in config_structure:
198
+ self.logger.debug(f"Processing item: {item}")
199
+ for name, content in item.items():
200
+ self.logger.debug(f"Processing name: {name}, content: {content}")
201
+ if isinstance(content, dict):
202
+ content["name"] = name
203
+ content["global_system_prompt"] = args.global_system_prompt
204
+ content["config_variables"] = config_variables
205
+ content["input_store"] = args.input_store
206
+ content["non_interactive"] = args.non_interactive
207
+ content["mappings"] = mappings or {}
208
+ file_item = FileItem(content)
209
+ file_item.fetch_content()
210
+ elif isinstance(content, str):
211
+ file_item = FileItem(
212
+ {
213
+ "name": name,
214
+ "content": content,
215
+ "config_variables": config_variables,
216
+ "input_store": args.input_store,
217
+ "non_interactive": args.non_interactive,
218
+ "mappings": mappings or {},
219
+ }
220
+ )
221
+
222
+ # Determine the full file path
223
+ file_path_to_create = os.path.join(args.base_path, name)
224
+ existing_content = None
225
+ if os.path.exists(file_path_to_create):
226
+ self.logger.info(f"ℹ️ Exists: {file_path_to_create}")
227
+ with open(file_path_to_create, 'r') as existing_file:
228
+ existing_content = existing_file.read()
229
+
230
+ file_item.process_prompt(
231
+ args.dry_run,
232
+ existing_content=existing_content
233
+ )
234
+ file_item.apply_template_variables(template_vars)
235
+
236
+ # Output mode logic with diff support
237
+ if hasattr(args, 'output') and args.output == 'console':
238
+ print(f"=== {file_path_to_create} ===")
239
+ if args.diff and existing_content is not None:
240
+ import difflib
241
+ new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n"
242
+ old_content = existing_content if existing_content.endswith("\n") else existing_content + "\n"
243
+ diff = difflib.unified_diff(
244
+ old_content.splitlines(keepends=True),
245
+ new_content.splitlines(keepends=True),
246
+ fromfile=f"a/{file_path_to_create}",
247
+ tofile=f"b/{file_path_to_create}",
248
+ )
249
+ print("".join(diff))
250
+ else:
251
+ print(file_item.content)
252
+ else:
253
+ # When dry-run with --diff and files mode, print action and diff instead of writing
254
+ if args.dry_run and args.diff:
255
+ action = "create"
256
+ if existing_content is not None:
257
+ action = "update"
258
+ print(f"[DRY RUN] {action}: {file_path_to_create}")
259
+ if action == "create":
260
+ summary["dry_run_created"] += 1
261
+ else:
262
+ summary["dry_run_updated"] += 1
263
+ import difflib
264
+ new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n"
265
+ old_content = (existing_content if existing_content is not None else "")
266
+ old_content = old_content if old_content.endswith("\n") else (old_content + ("\n" if old_content else ""))
267
+ diff = difflib.unified_diff(
268
+ old_content.splitlines(keepends=True),
269
+ new_content.splitlines(keepends=True),
270
+ fromfile=f"a/{file_path_to_create}",
271
+ tofile=f"b/{file_path_to_create}",
272
+ )
273
+ print("".join(diff))
274
+ else:
275
+ result = file_item.create(
276
+ args.base_path,
277
+ args.dry_run or False,
278
+ args.backup or None,
279
+ args.file_strategy or 'overwrite'
280
+ )
281
+ if isinstance(result, dict):
282
+ if result.get("action") == "created":
283
+ summary["created"] += 1
284
+ elif result.get("action") == "updated":
285
+ summary["updated"] += 1
286
+ elif result.get("action") == "appended":
287
+ summary["appended"] += 1
288
+ elif result.get("action") == "skipped":
289
+ summary["skipped"] += 1
290
+ if result.get("backed_up_to"):
291
+ summary["backed_up"] += 1
292
+ if result.get("renamed_from"):
293
+ summary["renamed"] += 1
294
+
295
+ for item in config_folders:
296
+ for folder, content in item.items():
297
+ folder_path = os.path.join(args.base_path, folder)
298
+ if hasattr(args, 'output') and args.output == 'file':
299
+ os.makedirs(folder_path, exist_ok=True)
300
+ self.logger.info(f"📁 Created folder: {folder_path}")
301
+ summary["folders"] += 1
302
+
303
+ # check if content has structkit value
304
+ if 'struct' in content:
305
+ self.logger.info(f"Generating structure")
306
+ self.logger.info(f" Folder: {folder}")
307
+ self.logger.info(f" Struct(s):")
308
+ if isinstance(content['struct'], list):
309
+ # iterate over the list of structures
310
+ for struct in content['struct']:
311
+ self.logger.info(f" - {struct}")
312
+ if isinstance(content['struct'], str):
313
+ self.logger.info(f" - {content['struct']}")
314
+
315
+ # get vars from with param. this will be a dict of key value pairs
316
+ merged_vars = ""
317
+
318
+ # dict to comma separated string
319
+ if 'with' in content:
320
+ if isinstance(content['with'], dict):
321
+ # Render Jinja2 expressions in each value using TemplateRenderer
322
+ rendered_with = {}
323
+ renderer = TemplateRenderer(
324
+ config_variables, args.input_store, args.non_interactive, mappings)
325
+ for k, v in content['with'].items():
326
+ # Render the value as a template, passing in mappings and template_vars
327
+ context = template_vars.copy() if template_vars else {}
328
+ context['mappings'] = mappings or {}
329
+ rendered_with[k] = renderer.render_template(str(v), context)
330
+ merged_vars = ",".join(
331
+ [f"{k}={v}" for k, v in rendered_with.items()])
332
+
333
+ # Merge parent args.vars safely without introducing trailing commas
334
+ if getattr(args, 'vars', None):
335
+ parts = []
336
+ parent_vars = args.vars.strip().strip(',')
337
+ if parent_vars:
338
+ parts.append(parent_vars)
339
+ if merged_vars:
340
+ parts.append(merged_vars)
341
+ merged_vars = ",".join(parts)
342
+
343
+ # If nothing to merge, keep None to avoid accidental truthiness with empty string
344
+ merged_vars = merged_vars if merged_vars else None
345
+
346
+ if isinstance(content['struct'], str):
347
+ self._create_structure({
348
+ 'structure_definition': content['struct'],
349
+ 'base_path': folder_path,
350
+ 'structures_path': args.structures_path,
351
+ 'dry_run': args.dry_run,
352
+ 'diff': getattr(args, 'diff', False),
353
+ 'output': getattr(args, 'output', 'file'),
354
+ 'vars': merged_vars,
355
+ 'backup': args.backup,
356
+ 'file_strategy': args.file_strategy,
357
+ 'global_system_prompt': args.global_system_prompt,
358
+ 'input_store': args.input_store,
359
+ 'non_interactive': args.non_interactive,
360
+ }, mappings=mappings, summary=summary, print_summary=False)
361
+ elif isinstance(content['struct'], list):
362
+ for struct in content['struct']:
363
+ self._create_structure({
364
+ 'structure_definition': struct,
365
+ 'base_path': folder_path,
366
+ 'structures_path': args.structures_path,
367
+ 'dry_run': args.dry_run,
368
+ 'diff': getattr(args, 'diff', False),
369
+ 'output': getattr(args, 'output', 'file'),
370
+ 'vars': merged_vars,
371
+ 'backup': args.backup,
372
+ 'file_strategy': args.file_strategy,
373
+ 'global_system_prompt': args.global_system_prompt,
374
+ 'input_store': args.input_store,
375
+ 'non_interactive': args.non_interactive,
376
+ }, mappings=mappings, summary=summary, print_summary=False)
377
+ else:
378
+ self.logger.warning(f"Unsupported content in folder: {folder}")
379
+
380
+ # Final summary (only once for top-level call)
381
+ if print_summary:
382
+ self.logger.info("")
383
+ self.logger.info("Summary of actions:")
384
+ self.logger.info(f" ✅ Created: {summary['created']}")
385
+ self.logger.info(f" ✅ Updated: {summary['updated']}")
386
+ self.logger.info(f" 📝 Appended: {summary['appended']}")
387
+ self.logger.info(f" ⏭️ Skipped: {summary['skipped']}")
388
+ self.logger.info(f" 🗄️ Backed up: {summary['backed_up']}")
389
+ self.logger.info(f" 🔁 Renamed: {summary['renamed']}")
390
+ self.logger.info(f" 📁 Folders created: {summary['folders']}")
391
+ if args.dry_run:
392
+ self.logger.info(
393
+ f" [DRY RUN] Would create: {summary['dry_run_created']}")
394
+ self.logger.info(
395
+ f" [DRY RUN] Would update: {summary['dry_run_updated']}")
396
+
397
+ return summary
@@ -0,0 +1,67 @@
1
+ from structkit.commands import Command
2
+ import os
3
+ import json
4
+
5
+ # Generate Schema command class
6
+ class GenerateSchemaCommand(Command):
7
+ def __init__(self, parser):
8
+ super().__init__(parser)
9
+ parser.description = "Generate JSON schema for available structures"
10
+ parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions')
11
+ parser.add_argument('-o', '--output', type=str, help='Output file path for the schema (default: stdout)')
12
+ parser.set_defaults(func=self.execute)
13
+
14
+ def execute(self, args):
15
+ self.logger.info("Generating JSON schema for available structures")
16
+ self._generate_schema(args)
17
+
18
+ def _generate_schema(self, args):
19
+ # Get the path to contribs directory (built-in structures)
20
+ this_file = os.path.dirname(os.path.realpath(__file__))
21
+ contribs_path = os.path.join(this_file, "..", "contribs")
22
+
23
+ # Determine paths to scan
24
+ if args.structures_path:
25
+ final_path = args.structures_path
26
+ paths_to_list = [final_path, contribs_path]
27
+ else:
28
+ paths_to_list = [contribs_path]
29
+
30
+ # Collect all available structures
31
+ all_structures = set()
32
+ for path in paths_to_list:
33
+ if os.path.exists(path):
34
+ for root, _, files in os.walk(path):
35
+ for file in files:
36
+ if file.endswith(".yaml"):
37
+ file_path = os.path.join(root, file)
38
+ rel_path = os.path.relpath(file_path, path)
39
+ # Remove .yaml extension
40
+ rel_path = rel_path[:-5]
41
+ all_structures.add(rel_path)
42
+
43
+ # Create JSON schema
44
+ schema = {
45
+ "definitions": {
46
+ "PluginList": {
47
+ "enum": sorted(list(all_structures))
48
+ }
49
+ }
50
+ }
51
+
52
+ # Convert to JSON string
53
+ json_output = json.dumps(schema, indent=2)
54
+
55
+ # Output to file or stdout
56
+ if args.output:
57
+ # Create output directory if it doesn't exist
58
+ output_dir = os.path.dirname(args.output)
59
+ if output_dir and not os.path.exists(output_dir):
60
+ os.makedirs(output_dir)
61
+
62
+ with open(args.output, 'w') as f:
63
+ f.write(json_output)
64
+ self.logger.info(f"Schema written to {args.output}")
65
+ print(f"✅ Schema successfully generated at: {args.output}")
66
+ else:
67
+ print(json_output)
@@ -0,0 +1,63 @@
1
+ from structkit.commands import Command
2
+ import os
3
+ import yaml
4
+
5
+ # Import command class
6
+ # This class is responsible for importing a structure definition given a file path. It will create the structure in the base path provided.
7
+ class ImportCommand(Command):
8
+ def __init__(self, parser):
9
+ super().__init__(parser)
10
+ parser.add_argument('import_from', type=str, help='Directory to import the structure from')
11
+ parser.add_argument('-o', '--output-path', type=str, help='Path to the output directory', default='.')
12
+ parser.set_defaults(func=self.execute)
13
+
14
+ def execute(self, args):
15
+ self.logger.info(f"Importing structure from {args.import_from} to {args.output_path}")
16
+
17
+ self._import_structure(args)
18
+
19
+
20
+ def _import_structure(self, args):
21
+ raise NotImplementedError("This method needs to be implemented")
22
+ # # Check if the import_from directory exists
23
+ # if not os.path.exists(args.import_from):
24
+ # self.logger.error(f"Directory not found: {args.import_from}")
25
+ # return
26
+
27
+ # # Define the path for the structure.yaml file
28
+ # structure_definition = os.path.join(args.output_path, 'structure.yaml')
29
+
30
+ # # Check if the output_path exists, if not, create it
31
+ # if not os.path.exists(args.output_path):
32
+ # self.logger.warning(f"Output path not found: {args.output_path}, creating it")
33
+ # os.makedirs(args.output_path)
34
+
35
+ # # Check if the structure.yaml file already exists
36
+ # if os.path.exists(structure_definition):
37
+ # self.logger.warning(f"Structure definition already exists at {structure_definition}")
38
+ # # Ask the user if they want to overwrite the existing structure.yaml file
39
+ # if input("Do you want to overwrite it? (y/N) ").lower() != 'y':
40
+ # return
41
+
42
+ # # Initialize the structure dictionary
43
+ # generated_structure = {
44
+ # 'structure': [],
45
+ # 'folders': [],
46
+ # 'variables': []
47
+ # }
48
+
49
+ # for root, dirs, files in os.walk(args.import_from):
50
+ # for file in files:
51
+ # self.logger.info(f"Processing file {file}")
52
+ # file_path = os.path.join(root, file)
53
+ # relative_path = os.path.relpath(file_path, args.import_from)
54
+ # generated_structure['structure'].append(
55
+ # {
56
+ # 'path': relative_path,
57
+ # 'content': open(file_path, 'r').read()
58
+ # }
59
+ # )
60
+
61
+ # with open(structure_definition, 'w') as f:
62
+ # self.logger.info(f"Writing structure definition to {structure_definition}")
63
+ # yaml.dump(generated_structure, f)