Skip to content

Commit 603285f

Browse files
authored
Add 'vars' command to inspect structure variables (#140)
1 parent 022b545 commit 603285f

8 files changed

Lines changed: 448 additions & 2 deletions

File tree

docs/cli-reference.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The `struct` CLI allows you to generate project structures from YAML configurati
99
**Basic Usage:**
1010

1111
```sh
12-
structkit {info,validate,generate,list,generate-schema,mcp,completion,init} ...
12+
structkit {info,validate,generate,vars,list,generate-schema,mcp,completion,init} ...
1313
```
1414

1515
## Global Options
@@ -115,6 +115,30 @@ structkit generate
115115
- `--mappings-file MAPPINGS_FILE`: Path to a YAML file containing mappings to be used in templates (can be specified multiple times).
116116
- `-o {console,file}, --output {console,file}`: Output mode.
117117

118+
### `vars`
119+
120+
Inspect variables declared by a structure definition without generating files.
121+
122+
**Usage:**
123+
124+
```sh
125+
structkit vars [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [--json] structure_definition
126+
```
127+
128+
**Arguments:**
129+
130+
- `structure_definition`: Built-in structure name, custom structure name, or local YAML file path. Local `.yaml` and `.yml` files can be passed directly, or with `file://`.
131+
- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to custom structure definitions. Can be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable.
132+
- `--json`: Print machine-readable JSON with each variable's name, type, default value, description/help text, and required status.
133+
134+
Examples:
135+
136+
```sh
137+
structkit vars project/python
138+
structkit vars ./my-struct.yaml --json
139+
structkit vars python-basic --structures-path ~/custom-structures
140+
```
141+
118142
### `list`
119143

120144
List available structures.

docs/mcp-integration.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,26 @@ Generate a project structure using specified definition and options.
6969
- `mappings` (optional): Variable mappings for template substitution
7070
- `structures_path` (optional): Custom path to structure definitions
7171

72-
### 4. validate_structure
72+
### 4. get_structure_vars
73+
Inspect variables declared by a specific structure without generating files.
74+
75+
```json
76+
{
77+
"name": "get_structure_vars",
78+
"arguments": {
79+
"structure_name": "project/python",
80+
"structures_path": "/path/to/custom/structures", // optional
81+
"output": "json" // "text" or "json", optional
82+
}
83+
}
84+
```
85+
86+
**Parameters:**
87+
- `structure_name` (required): Name or local YAML path of the structure to inspect
88+
- `structures_path` (optional): Custom path to structure definitions
89+
- `output` (optional): Output format - "text" for aligned human-readable output or "json" for machine-readable output (default: "text")
90+
91+
### 5. validate_structure
7392
Validate a structure configuration YAML file.
7493

7594
```json
@@ -353,6 +372,7 @@ Once connected, you can use these tools:
353372
- `list_structures` - Get all available structures
354373
- `get_structure_info` - Get details about a specific structure
355374
- `generate_structure` - Generate project structures
375+
- `get_structure_vars` - Inspect declared structure variables
356376
- `validate_structure` - Validate YAML configuration files
357377

358378
## Troubleshooting

docs/usage.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Run the script with the following command using one of the following subcommands
66
- `generate-schema`: Generate JSON schema for available structure templates.
77
- `validate`: Validate the YAML configuration file.
88
- `info`: Display information about the script and its dependencies.
9+
- `vars`: Inspect variables declared by a structure definition without generating files.
910
- `list`: List the available structs
1011

1112
For more information, run the script with the `-h` or `--help` option (this is also available for each subcommand):
@@ -145,6 +146,19 @@ The file includes:
145146
- A README.md placeholder in files
146147
- A folders entry pointing to the github/workflows/run-structkit workflow at ./
147148

149+
150+
### Inspect Variables
151+
152+
Use `structkit vars` to see the inputs a structure declares before running `generate`. The command supports built-in structures, custom structures via `--structures-path`, and local YAML files without creating any files.
153+
154+
```sh
155+
structkit vars project/python
156+
structkit vars ./my-struct.yaml --json
157+
structkit vars python-basic --structures-path ~/custom-structures
158+
```
159+
160+
Text output lists each variable's name, type, default value, description/help text, and whether it is required or optional. Use `--json` for CI and other machine-readable workflows.
161+
148162
### Validate Configuration
149163

150164
```sh

structkit/commands/vars.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import json
2+
import os
3+
import yaml
4+
5+
from structkit.commands import Command
6+
from structkit.completers import structures_completer
7+
8+
9+
class VarsCommand(Command):
10+
"""Inspect variables declared by a structure definition."""
11+
12+
def __init__(self, parser):
13+
super().__init__(parser)
14+
parser.description = "Inspect variables declared by a structure definition"
15+
structure_arg = parser.add_argument('structure_definition', type=str, help='Structure definition name or path to a YAML file')
16+
structure_arg.completer = structures_completer
17+
parser.add_argument(
18+
'-s',
19+
'--structures-path',
20+
type=str,
21+
help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
22+
default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None)
23+
)
24+
parser.add_argument('--json', action='store_true', help='Output variables as JSON')
25+
parser.set_defaults(func=self.execute)
26+
27+
def execute(self, args):
28+
config = self._load_yaml_config(args.structure_definition, args.structures_path)
29+
if config is None:
30+
raise SystemExit(1)
31+
if not isinstance(config, dict):
32+
self.logger.error("❗ Invalid structure config: top-level YAML content must be a mapping")
33+
raise SystemExit(1)
34+
35+
try:
36+
variables = self._normalize_variables(config.get('variables', []))
37+
except ValueError as exc:
38+
self.logger.error(f"❗ Invalid variables config: {exc}")
39+
raise SystemExit(1) from exc
40+
41+
if args.json:
42+
print(json.dumps(variables, indent=2))
43+
else:
44+
self._print_text(args.structure_definition, variables)
45+
46+
def _load_yaml_config(self, structure_definition, structures_path):
47+
if structure_definition.endswith(('.yaml', '.yml')) and not structure_definition.startswith("file://"):
48+
structure_definition = f"file://{structure_definition}"
49+
50+
if structure_definition.startswith("file://") and structure_definition.endswith((".yaml", ".yml")):
51+
file_path = structure_definition[7:]
52+
else:
53+
this_file = os.path.dirname(os.path.realpath(__file__))
54+
contribs_path = os.path.join(this_file, "..", "contribs")
55+
file_path = os.path.join(contribs_path, f"{structure_definition}.yaml")
56+
if structures_path:
57+
file_path = os.path.join(structures_path, f"{structure_definition}.yaml")
58+
if not os.path.exists(file_path):
59+
file_path = os.path.join(contribs_path, f"{structure_definition}.yaml")
60+
61+
if not os.path.exists(file_path):
62+
self.logger.error(f"❗ File not found: {file_path}")
63+
return None
64+
65+
try:
66+
with open(file_path, 'r') as f:
67+
return yaml.safe_load(f) or {}
68+
except yaml.YAMLError as exc:
69+
self.logger.error(f"❗ Invalid YAML in {file_path}: {exc}")
70+
return None
71+
except OSError as exc:
72+
self.logger.error(f"❗ Failed to read {file_path}: {exc}")
73+
return None
74+
75+
def _normalize_variables(self, variables):
76+
if variables is None:
77+
return []
78+
if not isinstance(variables, list):
79+
raise ValueError("the 'variables' key must be a list")
80+
81+
normalized = []
82+
for item in variables:
83+
if not isinstance(item, dict):
84+
raise ValueError("each variable entry must be a mapping")
85+
for name, content in item.items():
86+
if not isinstance(name, str):
87+
raise ValueError("each variable name must be a string")
88+
if content is None:
89+
content = {}
90+
if not isinstance(content, dict):
91+
raise ValueError(f"the content of '{name}' must be a mapping")
92+
93+
has_default = 'default' in content
94+
description = content.get('description', content.get('help', ''))
95+
normalized.append({
96+
'name': name,
97+
'type': content.get('type', ''),
98+
'default': content.get('default') if has_default else None,
99+
'description': description if description is not None else '',
100+
'required': bool(content.get('required', False)),
101+
})
102+
return normalized
103+
104+
def _print_text(self, structure_definition, variables):
105+
print(f"Variables for {structure_definition}")
106+
if not variables:
107+
print("No variables defined.")
108+
return
109+
110+
rows = [[
111+
variable['name'],
112+
variable['type'] or '-',
113+
self._format_default(variable['default']),
114+
'required' if variable['required'] else 'optional',
115+
variable['description'] or '-',
116+
] for variable in variables]
117+
headers = ['Name', 'Type', 'Default', 'Required', 'Description']
118+
widths = [len(header) for header in headers]
119+
for row in rows:
120+
for index, value in enumerate(row):
121+
widths[index] = max(widths[index], len(value))
122+
123+
print(" " + " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
124+
print(" " + " ".join("-" * width for width in widths))
125+
for row in rows:
126+
print(" " + " ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
127+
128+
def _format_default(self, value):
129+
if value is None:
130+
return '-'
131+
if isinstance(value, bool):
132+
return str(value).lower()
133+
return str(value)

structkit/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from structkit.utils import read_config_file, merge_configs
66
from structkit.commands.generate import GenerateCommand
77
from structkit.commands.info import InfoCommand
8+
from structkit.commands.vars import VarsCommand
89
from structkit.commands.validate import ValidateCommand
910
from structkit.commands.list import ListCommand
1011
from structkit.commands.search import SearchCommand
@@ -34,6 +35,7 @@ def get_parser():
3435
InfoCommand(subparsers.add_parser('info', help='Show information about the package'))
3536
ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file'))
3637
GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure'))
38+
VarsCommand(subparsers.add_parser('vars', help='Inspect structure variables'))
3739
ListCommand(subparsers.add_parser('list', help='List available structures'))
3840
SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword'))
3941
GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures'))

structkit/mcp_server.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
2. Getting detailed information about structures
77
3. Generating structures with various options
88
4. Validating structure configurations
9+
5. Inspecting structure variables
910
"""
1011
import asyncio
1112
import logging
@@ -18,6 +19,7 @@
1819

1920
from structkit.commands.generate import GenerateCommand
2021
from structkit.commands.validate import ValidateCommand
22+
from structkit.commands.vars import VarsCommand
2123
from structkit import __version__
2224

2325

@@ -193,6 +195,44 @@ class Args:
193195
finally:
194196
sys.stdout = old
195197

198+
def _get_structure_vars_logic(
199+
self,
200+
structure_name: Optional[str],
201+
structures_path: Optional[str] = None,
202+
output: str = "text",
203+
) -> str:
204+
if not structure_name:
205+
return "Error: structure_name is required"
206+
207+
import argparse
208+
from io import StringIO
209+
dummy_parser = argparse.ArgumentParser()
210+
vars_command = VarsCommand(dummy_parser)
211+
212+
config = vars_command._load_yaml_config(structure_name, structures_path)
213+
if config is None:
214+
return f"❗ Structure not found or could not be loaded: {structure_name}"
215+
if not isinstance(config, dict):
216+
return "❗ Invalid structure config: top-level YAML content must be a mapping"
217+
218+
try:
219+
variables = vars_command._normalize_variables(config.get('variables', []))
220+
except ValueError as exc:
221+
return f"❗ Invalid variables config: {exc}"
222+
223+
if output == "json":
224+
import json
225+
return json.dumps(variables, indent=2)
226+
227+
buf = StringIO()
228+
old = sys.stdout
229+
sys.stdout = buf
230+
try:
231+
vars_command._print_text(structure_name, variables)
232+
return buf.getvalue().strip()
233+
finally:
234+
sys.stdout = old
235+
196236
# =====================
197237
# FastMCP tool registration (maps to logic above)
198238
# =====================
@@ -215,6 +255,25 @@ async def get_structure_info(structure_name: str, structures_path: Optional[str]
215255
self.logger.debug(f"MCP response: get_structure_info len={len(result)} preview=\n{preview}")
216256
return result
217257

258+
@self.app.tool(name="get_structure_vars", description="Inspect variables declared by a specific structure")
259+
async def get_structure_vars(
260+
structure_name: str,
261+
structures_path: Optional[str] = None,
262+
output: str = "text",
263+
) -> str:
264+
self.logger.debug(
265+
"MCP request: get_structure_vars args=%s",
266+
{
267+
"structure_name": structure_name,
268+
"structures_path": structures_path,
269+
"output": output,
270+
},
271+
)
272+
result = self._get_structure_vars_logic(structure_name, structures_path, output)
273+
preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]"
274+
self.logger.debug(f"MCP response: get_structure_vars len={len(result)} preview=\n{preview}")
275+
return result
276+
218277
@self.app.tool(name="generate_structure", description="Generate a project structure using specified definition and options")
219278
async def generate_structure(
220279
structure_definition: str,
@@ -337,6 +396,25 @@ def __init__(self, content):
337396

338397
return MockResult([MockContent(result_text)])
339398

399+
async def _handle_get_structure_vars(self, params: Dict[str, Any]):
400+
"""Compatibility method for tests that expect MCP-style responses."""
401+
structure_name = params.get('structure_name')
402+
structures_path = params.get('structures_path')
403+
output = params.get('output', 'text')
404+
405+
result_text = self._get_structure_vars_logic(structure_name, structures_path, output)
406+
407+
# Mock MCP response structure
408+
class MockContent:
409+
def __init__(self, text):
410+
self.text = text
411+
412+
class MockResult:
413+
def __init__(self, content):
414+
self.content = content
415+
416+
return MockResult([MockContent(result_text)])
417+
340418

341419
async def main():
342420
logging.basicConfig(level=logging.INFO)

0 commit comments

Comments
 (0)