From dc2943375a8c9c33173b09a3b34f36a0d4860220 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Thu, 7 May 2026 23:53:10 -0300 Subject: [PATCH 1/2] Add vars command for structure variables --- docs/cli-reference.md | 26 +++++++- docs/usage.md | 14 ++++ structkit/commands/vars.py | 133 +++++++++++++++++++++++++++++++++++++ structkit/main.py | 2 + tests/test_commands.py | 98 +++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 structkit/commands/vars.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d8d0089..de21f97 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,7 +9,7 @@ The `struct` CLI allows you to generate project structures from YAML configurati **Basic Usage:** ```sh -structkit {info,validate,generate,list,generate-schema,mcp,completion,init} ... +structkit {info,validate,generate,vars,list,generate-schema,mcp,completion,init} ... ``` ## Global Options @@ -115,6 +115,30 @@ structkit generate - `--mappings-file MAPPINGS_FILE`: Path to a YAML file containing mappings to be used in templates (can be specified multiple times). - `-o {console,file}, --output {console,file}`: Output mode. +### `vars` + +Inspect variables declared by a structure definition without generating files. + +**Usage:** + +```sh +structkit vars [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [--json] structure_definition +``` + +**Arguments:** + +- `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://`. +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to custom structure definitions. Can be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable. +- `--json`: Print machine-readable JSON with each variable's name, type, default value, description/help text, and required status. + +Examples: + +```sh +structkit vars project/python +structkit vars ./my-struct.yaml --json +structkit vars python-basic --structures-path ~/custom-structures +``` + ### `list` List available structures. diff --git a/docs/usage.md b/docs/usage.md index 81eb32e..635e2cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,6 +6,7 @@ Run the script with the following command using one of the following subcommands - `generate-schema`: Generate JSON schema for available structure templates. - `validate`: Validate the YAML configuration file. - `info`: Display information about the script and its dependencies. +- `vars`: Inspect variables declared by a structure definition without generating files. - `list`: List the available structs 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: - A README.md placeholder in files - A folders entry pointing to the github/workflows/run-structkit workflow at ./ + +### Inspect Variables + +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. + +```sh +structkit vars project/python +structkit vars ./my-struct.yaml --json +structkit vars python-basic --structures-path ~/custom-structures +``` + +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. + ### Validate Configuration ```sh diff --git a/structkit/commands/vars.py b/structkit/commands/vars.py new file mode 100644 index 0000000..7d7d8ef --- /dev/null +++ b/structkit/commands/vars.py @@ -0,0 +1,133 @@ +import json +import os +import yaml + +from structkit.commands import Command +from structkit.completers import structures_completer + + +class VarsCommand(Command): + """Inspect variables declared by a structure definition.""" + + def __init__(self, parser): + super().__init__(parser) + parser.description = "Inspect variables declared by a structure definition" + structure_arg = parser.add_argument('structure_definition', type=str, help='Structure definition name or path to a YAML file') + structure_arg.completer = structures_completer + parser.add_argument( + '-s', + '--structures-path', + type=str, + help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None) + ) + parser.add_argument('--json', action='store_true', help='Output variables as JSON') + parser.set_defaults(func=self.execute) + + def execute(self, args): + config = self._load_yaml_config(args.structure_definition, args.structures_path) + if config is None: + raise SystemExit(1) + if not isinstance(config, dict): + self.logger.error("❗ Invalid structure config: top-level YAML content must be a mapping") + raise SystemExit(1) + + try: + variables = self._normalize_variables(config.get('variables', [])) + except ValueError as exc: + self.logger.error(f"❗ Invalid variables config: {exc}") + raise SystemExit(1) from exc + + if args.json: + print(json.dumps(variables, indent=2)) + else: + self._print_text(args.structure_definition, variables) + + def _load_yaml_config(self, structure_definition, structures_path): + if structure_definition.endswith(('.yaml', '.yml')) and not structure_definition.startswith("file://"): + structure_definition = f"file://{structure_definition}" + + if structure_definition.startswith("file://") and structure_definition.endswith((".yaml", ".yml")): + file_path = structure_definition[7:] + else: + this_file = os.path.dirname(os.path.realpath(__file__)) + contribs_path = os.path.join(this_file, "..", "contribs") + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + if structures_path: + file_path = os.path.join(structures_path, f"{structure_definition}.yaml") + if not os.path.exists(file_path): + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + + if not os.path.exists(file_path): + self.logger.error(f"❗ File not found: {file_path}") + return None + + try: + with open(file_path, 'r') as f: + return yaml.safe_load(f) or {} + except yaml.YAMLError as exc: + self.logger.error(f"❗ Invalid YAML in {file_path}: {exc}") + return None + except OSError as exc: + self.logger.error(f"❗ Failed to read {file_path}: {exc}") + return None + + def _normalize_variables(self, variables): + if variables is None: + return [] + if not isinstance(variables, list): + raise ValueError("the 'variables' key must be a list") + + normalized = [] + for item in variables: + if not isinstance(item, dict): + raise ValueError("each variable entry must be a mapping") + for name, content in item.items(): + if not isinstance(name, str): + raise ValueError("each variable name must be a string") + if content is None: + content = {} + if not isinstance(content, dict): + raise ValueError(f"the content of '{name}' must be a mapping") + + has_default = 'default' in content + description = content.get('description', content.get('help', '')) + normalized.append({ + 'name': name, + 'type': content.get('type', ''), + 'default': content.get('default') if has_default else None, + 'description': description if description is not None else '', + 'required': bool(content.get('required', False)), + }) + return normalized + + def _print_text(self, structure_definition, variables): + print(f"Variables for {structure_definition}") + if not variables: + print("No variables defined.") + return + + rows = [[ + variable['name'], + variable['type'] or '-', + self._format_default(variable['default']), + 'required' if variable['required'] else 'optional', + variable['description'] or '-', + ] for variable in variables] + headers = ['Name', 'Type', 'Default', 'Required', 'Description'] + widths = [len(header) for header in headers] + for row in rows: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + print(" " + " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))) + print(" " + " ".join("-" * width for width in widths)) + for row in rows: + print(" " + " ".join(value.ljust(widths[index]) for index, value in enumerate(row))) + + def _format_default(self, value): + if value is None: + return '-' + if isinstance(value, bool): + return str(value).lower() + return str(value) diff --git a/structkit/main.py b/structkit/main.py index 73e499c..5a0eb7c 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -5,6 +5,7 @@ from structkit.utils import read_config_file, merge_configs from structkit.commands.generate import GenerateCommand from structkit.commands.info import InfoCommand +from structkit.commands.vars import VarsCommand from structkit.commands.validate import ValidateCommand from structkit.commands.list import ListCommand from structkit.commands.search import SearchCommand @@ -34,6 +35,7 @@ def get_parser(): InfoCommand(subparsers.add_parser('info', help='Show information about the package')) ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file')) GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) + VarsCommand(subparsers.add_parser('vars', help='Inspect structure variables')) ListCommand(subparsers.add_parser('list', help='List available structures')) SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword')) GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) diff --git a/tests/test_commands.py b/tests/test_commands.py index 0f9598f..b6ea1c0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,6 +5,7 @@ from structkit.commands.validate import ValidateCommand from structkit.commands.list import ListCommand from structkit.commands.generate_schema import GenerateSchemaCommand +from structkit.commands.vars import VarsCommand import argparse import json import os @@ -520,3 +521,100 @@ def test_multiple_mappings_files(): } assert merged_mappings == expected_mappings + +# Tests for VarsCommand +def test_vars_command_text_output(tmp_path, capsys): + yaml_file = tmp_path / "structure.yaml" + yaml_file.write_text(""" +variables: + - project_name: + description: Project name + type: string + default: MyProject + - api_token: + help: API token + type: string + required: true +""") + command = VarsCommand(parser := argparse.ArgumentParser()) + args = parser.parse_args([str(yaml_file)]) + + command.execute(args) + + output = capsys.readouterr().out + assert "Variables for" in output + assert "project_name" in output + assert "string" in output + assert "MyProject" in output + assert "optional" in output + assert "api_token" in output + assert "required" in output + assert "API token" in output + + +def test_vars_command_json_output(tmp_path, capsys): + yaml_file = tmp_path / "structure.yaml" + yaml_file.write_text(""" +variables: + - enabled: + description: Enable feature + type: boolean + default: true + required: true +""") + command = VarsCommand(parser := argparse.ArgumentParser()) + args = parser.parse_args([str(yaml_file), '--json']) + + command.execute(args) + + output = capsys.readouterr().out + assert json.loads(output) == [ + { + "name": "enabled", + "type": "boolean", + "default": True, + "description": "Enable feature", + "required": True, + } + ] + + +def test_vars_command_no_variables(tmp_path, capsys): + yaml_file = tmp_path / "structure.yaml" + yaml_file.write_text("files: []\n") + command = VarsCommand(parser := argparse.ArgumentParser()) + args = parser.parse_args([str(yaml_file)]) + + command.execute(args) + + assert "No variables defined." in capsys.readouterr().out + + +def test_vars_command_custom_structures_path(tmp_path, capsys): + structures_path = tmp_path / "structures" + structures_path.mkdir() + (structures_path / "custom.yaml").write_text(""" +variables: + - custom_name: + type: string + description: Custom variable +""") + command = VarsCommand(parser := argparse.ArgumentParser()) + args = parser.parse_args(['custom', '--structures-path', str(structures_path)]) + + command.execute(args) + + output = capsys.readouterr().out + assert "custom_name" in output + assert "Custom variable" in output + + +def test_vars_command_invalid_config_exits_nonzero(tmp_path): + yaml_file = tmp_path / "structure.yaml" + yaml_file.write_text("variables: invalid\n") + command = VarsCommand(parser := argparse.ArgumentParser()) + args = parser.parse_args([str(yaml_file)]) + + with pytest.raises(SystemExit) as exc: + command.execute(args) + assert exc.value.code == 1 From eb55ef581a25c150b55690ede1da09103b266fa5 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 8 May 2026 22:09:48 -0300 Subject: [PATCH 2/2] Expose vars inspection through MCP --- docs/mcp-integration.md | 22 +++++++++- structkit/mcp_server.py | 78 +++++++++++++++++++++++++++++++++++ tests/test_mcp_integration.py | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index a47178c..f66f0ba 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -69,7 +69,26 @@ Generate a project structure using specified definition and options. - `mappings` (optional): Variable mappings for template substitution - `structures_path` (optional): Custom path to structure definitions -### 4. validate_structure +### 4. get_structure_vars +Inspect variables declared by a specific structure without generating files. + +```json +{ + "name": "get_structure_vars", + "arguments": { + "structure_name": "project/python", + "structures_path": "/path/to/custom/structures", // optional + "output": "json" // "text" or "json", optional + } +} +``` + +**Parameters:** +- `structure_name` (required): Name or local YAML path of the structure to inspect +- `structures_path` (optional): Custom path to structure definitions +- `output` (optional): Output format - "text" for aligned human-readable output or "json" for machine-readable output (default: "text") + +### 5. validate_structure Validate a structure configuration YAML file. ```json @@ -353,6 +372,7 @@ Once connected, you can use these tools: - `list_structures` - Get all available structures - `get_structure_info` - Get details about a specific structure - `generate_structure` - Generate project structures +- `get_structure_vars` - Inspect declared structure variables - `validate_structure` - Validate YAML configuration files ## Troubleshooting diff --git a/structkit/mcp_server.py b/structkit/mcp_server.py index e18eba4..3e17c04 100644 --- a/structkit/mcp_server.py +++ b/structkit/mcp_server.py @@ -6,6 +6,7 @@ 2. Getting detailed information about structures 3. Generating structures with various options 4. Validating structure configurations +5. Inspecting structure variables """ import asyncio import logging @@ -18,6 +19,7 @@ from structkit.commands.generate import GenerateCommand from structkit.commands.validate import ValidateCommand +from structkit.commands.vars import VarsCommand from structkit import __version__ @@ -193,6 +195,44 @@ class Args: finally: sys.stdout = old + def _get_structure_vars_logic( + self, + structure_name: Optional[str], + structures_path: Optional[str] = None, + output: str = "text", + ) -> str: + if not structure_name: + return "Error: structure_name is required" + + import argparse + from io import StringIO + dummy_parser = argparse.ArgumentParser() + vars_command = VarsCommand(dummy_parser) + + config = vars_command._load_yaml_config(structure_name, structures_path) + if config is None: + return f"❗ Structure not found or could not be loaded: {structure_name}" + if not isinstance(config, dict): + return "❗ Invalid structure config: top-level YAML content must be a mapping" + + try: + variables = vars_command._normalize_variables(config.get('variables', [])) + except ValueError as exc: + return f"❗ Invalid variables config: {exc}" + + if output == "json": + import json + return json.dumps(variables, indent=2) + + buf = StringIO() + old = sys.stdout + sys.stdout = buf + try: + vars_command._print_text(structure_name, variables) + return buf.getvalue().strip() + finally: + sys.stdout = old + # ===================== # FastMCP tool registration (maps to logic above) # ===================== @@ -215,6 +255,25 @@ async def get_structure_info(structure_name: str, structures_path: Optional[str] self.logger.debug(f"MCP response: get_structure_info len={len(result)} preview=\n{preview}") return result + @self.app.tool(name="get_structure_vars", description="Inspect variables declared by a specific structure") + async def get_structure_vars( + structure_name: str, + structures_path: Optional[str] = None, + output: str = "text", + ) -> str: + self.logger.debug( + "MCP request: get_structure_vars args=%s", + { + "structure_name": structure_name, + "structures_path": structures_path, + "output": output, + }, + ) + result = self._get_structure_vars_logic(structure_name, structures_path, output) + preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" + self.logger.debug(f"MCP response: get_structure_vars len={len(result)} preview=\n{preview}") + return result + @self.app.tool(name="generate_structure", description="Generate a project structure using specified definition and options") async def generate_structure( structure_definition: str, @@ -337,6 +396,25 @@ def __init__(self, content): return MockResult([MockContent(result_text)]) + async def _handle_get_structure_vars(self, params: Dict[str, Any]): + """Compatibility method for tests that expect MCP-style responses.""" + structure_name = params.get('structure_name') + structures_path = params.get('structures_path') + output = params.get('output', 'text') + + result_text = self._get_structure_vars_logic(structure_name, structures_path, output) + + # Mock MCP response structure + class MockContent: + def __init__(self, text): + self.text = text + + class MockResult: + def __init__(self, content): + self.content = content + + return MockResult([MockContent(result_text)]) + async def main(): logging.basicConfig(level=logging.INFO) diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index c3d0c7b..99eaab3 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,6 +1,8 @@ """ Tests for MCP (Model Context Protocol) integration with FastMCP stdio transport. """ +import asyncio +import json import os import tempfile import unittest @@ -19,6 +21,11 @@ def test_server_initialization(self): self.assertIsNotNone(self.server) self.assertTrue(hasattr(self.server, 'app')) + def test_get_structure_vars_tool_is_registered(self): + tools = asyncio.run(self.server.app.list_tools()) + tool_names = [tool.name for tool in tools] + self.assertIn('get_structure_vars', tool_names) + def test_list_structures_logic(self): text = self.server._list_structures_logic() self.assertIsInstance(text, str) @@ -43,6 +50,76 @@ def test_generate_structure_logic(self): ) self.assertIsInstance(text, str) + def test_get_structure_vars_logic(self): + text = self.server._get_structure_vars_logic(None) + self.assertIn("structure_name is required", text) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump({ + 'variables': [ + { + 'project_name': { + 'type': 'string', + 'default': 'MyProject', + 'description': 'Project name' + } + }, + { + 'api_token': { + 'type': 'string', + 'help': 'API token', + 'required': True + } + }, + ] + }, f) + f.flush() + try: + text = self.server._get_structure_vars_logic(f.name) + self.assertIn("Variables for", text) + self.assertIn("project_name", text) + self.assertIn("MyProject", text) + self.assertIn("api_token", text) + self.assertIn("required", text) + + json_text = self.server._get_structure_vars_logic(f.name, output="json") + data = json.loads(json_text) + self.assertEqual(data[0]['name'], 'project_name') + self.assertEqual(data[0]['default'], 'MyProject') + self.assertEqual(data[1]['name'], 'api_token') + self.assertTrue(data[1]['required']) + finally: + os.unlink(f.name) + + def test_get_structure_vars_logic_custom_path(self): + with tempfile.TemporaryDirectory() as temp_dir: + structure_path = os.path.join(temp_dir, 'custom.yaml') + with open(structure_path, 'w') as f: + yaml.dump({ + 'variables': [ + {'custom_name': {'type': 'string', 'description': 'Custom variable'}} + ] + }, f) + + text = self.server._get_structure_vars_logic('custom', structures_path=temp_dir) + self.assertIn('custom_name', text) + self.assertIn('Custom variable', text) + + def test_get_structure_vars_compat_handler(self): + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump({'variables': [{'enabled': {'type': 'boolean', 'default': True}}]}, f) + f.flush() + try: + result = asyncio.run(self.server._handle_get_structure_vars({ + 'structure_name': f.name, + 'output': 'json', + })) + data = json.loads(result.content[0].text) + self.assertEqual(data[0]['name'], 'enabled') + self.assertTrue(data[0]['default']) + finally: + os.unlink(f.name) + def test_validate_structure_logic(self): # Missing yaml_file text = self.server._validate_structure_logic(None)