diff --git a/README.md b/README.md index 1aadf92..af971d1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ struct validate my-config.yaml struct mcp --server ``` +### Shell Completion + +Enable tab completion for struct commands and options: + +```sh +# Print exact setup commands for your shell (auto-detects if omitted) +struct completion install + +# Or specify explicitly +struct completion install zsh +struct completion install bash +struct completion install fish +``` + ### 🤖 MCP Integration Quick Start Struct supports MCP (Model Context Protocol) for seamless AI tool integration: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 670aa44..0246a63 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 -struct {info,validate,generate,list,generate-schema} ... +struct {info,validate,generate,list,generate-schema,mcp,completion} ... ``` ## Global Options @@ -107,6 +107,19 @@ struct generate-schema [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTUR - `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. - `-o OUTPUT, --output OUTPUT`: Output file path for the schema (default: stdout). +### `completion` + +Manage shell completions for struct. + +Usage: + +```sh +struct completion install [bash|zsh|fish] +``` + +- If no shell is provided, the command attempts to auto-detect your current shell and prints the exact commands to enable argcomplete-based completion for struct. +- This does not modify your shell configuration; it only prints the commands you can copy-paste. + ## Examples ### Basic Structure Generation diff --git a/docs/completion.md b/docs/completion.md index 854ddde..a8cb7f8 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -7,6 +7,20 @@ STRUCT provides intelligent auto-completion for commands, options, and **structu ## Quick Setup +The easiest way is to ask struct to print the exact commands for your shell: + +```sh +# Auto-detect current shell and print install steps +struct completion install + +# Or specify explicitly +struct completion install zsh +struct completion install bash +struct completion install fish +``` + +You can still follow the manual steps below if you prefer. + For most users, this simple setup will enable full completion: ```sh diff --git a/struct_module/commands/completion.py b/struct_module/commands/completion.py new file mode 100644 index 0000000..cb7053b --- /dev/null +++ b/struct_module/commands/completion.py @@ -0,0 +1,60 @@ +from struct_module.commands import Command +import os + +SUPPORTED_SHELLS = ["bash", "zsh", "fish"] + +class CompletionCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.description = "Manage CLI shell completions for struct (argcomplete)" + sub = parser.add_subparsers(dest="action") + + install = sub.add_parser("install", help="Print the commands to enable completion for your shell") + install.add_argument("shell", nargs="?", choices=SUPPORTED_SHELLS, help="Shell type (auto-detected if omitted)") + install.set_defaults(func=self._install) + + def _detect_shell(self): + shell = os.environ.get("SHELL", "") + if shell: + basename = os.path.basename(shell) + if basename in SUPPORTED_SHELLS: + return basename + # Fallback to zsh if running zsh, else bash + if os.environ.get("ZSH_NAME") or os.environ.get("ZDOTDIR"): + return "zsh" + return "bash" + + def _install(self, args): + shell = args.shell or self._detect_shell() + print(f"Detected shell: {shell}") + + if shell == "bash": + print("\n# One-time dependency (if not installed):") + print("python -m pip install argcomplete") + print("\n# Enable completion for 'struct' in bash (append to ~/.bashrc):") + print('echo "eval \"$(register-python-argcomplete struct)\"" >> ~/.bashrc') + print("\n# Apply now:") + print("source ~/.bashrc") + + elif shell == "zsh": + print("\n# One-time dependency (if not installed):") + print("python -m pip install argcomplete") + print("\n# Enable completion for 'struct' in zsh (append to ~/.zshrc):") + print('echo "eval \"$(register-python-argcomplete --shell zsh struct)\"" >> ~/.zshrc') + print("\n# Apply now:") + print("source ~/.zshrc") + + elif shell == "fish": + print("\n# One-time dependency (if not installed):") + print("python -m pip install argcomplete") + print("\n# Install fish completion file for 'struct':") + print('mkdir -p ~/.config/fish/completions') + print('register-python-argcomplete --shell fish struct > ~/.config/fish/completions/struct.fish') + print("\n# Apply now:") + print("fish -c 'source ~/.config/fish/completions/struct.fish'") + + else: + self.logger.error(f"Unsupported shell: {shell}. Supported: {', '.join(SUPPORTED_SHELLS)}") + return + + print("\nTip: If 'register-python-argcomplete' is not found, try:\n python -m argcomplete.shellintegration ") diff --git a/struct_module/commands/init.py b/struct_module/commands/init.py new file mode 100644 index 0000000..8ea2638 --- /dev/null +++ b/struct_module/commands/init.py @@ -0,0 +1,52 @@ +from struct_module.commands import Command +import os +import textwrap + +BASIC_STRUCT_YAML = textwrap.dedent( + """ + # Generated by `struct init` + # Edit as needed to customize your project bootstrap + + pre_hooks: + - echo "Starting struct generation" + + post_hooks: + - echo "Struct generation completed" + + files: + - README.md: | + # Project + + Initialized with struct. + + folders: + - ./: + struct: + - github/workflows/run-struct + """ +).lstrip() + +class InitCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.description = "Initialize a basic .struct.yaml in the target directory" + parser.add_argument('path', nargs='?', default='.', help='Directory to initialize (default: current directory)') + parser.set_defaults(func=self.execute) + + def execute(self, args): + base_path = os.path.abspath(args.path or '.') + target = os.path.join(base_path, '.struct.yaml') + + os.makedirs(base_path, exist_ok=True) + + # If file exists, do not overwrite without explicit confirmation behavior (keep simple: skip) + if os.path.exists(target): + self.logger.info(f".struct.yaml already exists at {target}, skipping creation") + print(f"⚠️ .struct.yaml already exists at: {target}") + return + + with open(target, 'w') as f: + f.write(BASIC_STRUCT_YAML) + + print("✅ Created .struct.yaml") + print(f" - {target}") diff --git a/struct_module/main.py b/struct_module/main.py index a402c2e..5161542 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -31,6 +31,14 @@ def main(): GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support')) + # init to create a basic .struct.yaml + from struct_module.commands.init import InitCommand + InitCommand(subparsers.add_parser('init', help='Initialize a basic .struct.yaml in the target directory')) + + # completion installer + from struct_module.commands.completion import CompletionCommand + CompletionCommand(subparsers.add_parser('completion', help='Manage shell completions')) + argcomplete.autocomplete(parser) args = parser.parse_args() diff --git a/struct_module/mcp_server.py b/struct_module/mcp_server.py index 6e17cbd..d50fe78 100644 --- a/struct_module/mcp_server.py +++ b/struct_module/mcp_server.py @@ -96,9 +96,9 @@ async def handle_list_tools(request: ListToolsRequest) -> List[Tool]: }, "output": { "type": "string", - "enum": ["console", "files"], - "description": "Output mode: console for stdout or files for actual generation", - "default": "files" + "enum": ["console", "file"], + "description": "Output mode: console for stdout or file for actual generation", + "default": "file" }, "dry_run": { "type": "boolean", @@ -307,7 +307,10 @@ async def _handle_generate_structure(self, arguments: Dict[str, Any]) -> CallToo try: structure_definition = arguments.get("structure_definition") base_path = arguments.get("base_path") - output_mode = arguments.get("output", "files") + output_mode = arguments.get("output", "file") + # Backward compatibility: accept legacy 'files' and normalize to 'file' + if output_mode == "files": + output_mode = "file" dry_run = arguments.get("dry_run", False) mappings = arguments.get("mappings", {}) structures_path = arguments.get("structures_path") diff --git a/tests/test_completion_command.py b/tests/test_completion_command.py new file mode 100644 index 0000000..43a68a0 --- /dev/null +++ b/tests/test_completion_command.py @@ -0,0 +1,61 @@ +import argparse +import os +from unittest.mock import patch + +from struct_module.commands.completion import CompletionCommand + + +def make_parser(): + return argparse.ArgumentParser() + + +def _gather_print_output(mock_print): + return "\n".join(str(call.args[0]) for call in mock_print.call_args_list) + + +def test_completion_install_bash_explicit(): + parser = make_parser() + cmd = CompletionCommand(parser) + with patch('builtins.print') as mock_print: + args = parser.parse_args(['install', 'bash']) + cmd._install(args) + out = _gather_print_output(mock_print) + assert "Detected shell: bash" in out + assert "register-python-argcomplete struct" in out + assert "~/.bashrc" in out + + +def test_completion_install_zsh_explicit(): + parser = make_parser() + cmd = CompletionCommand(parser) + with patch('builtins.print') as mock_print: + args = parser.parse_args(['install', 'zsh']) + cmd._install(args) + out = _gather_print_output(mock_print) + assert "Detected shell: zsh" in out + assert "register-python-argcomplete --shell zsh struct" in out + assert "~/.zshrc" in out + + +def test_completion_install_fish_explicit(): + parser = make_parser() + cmd = CompletionCommand(parser) + with patch('builtins.print') as mock_print: + args = parser.parse_args(['install', 'fish']) + cmd._install(args) + out = _gather_print_output(mock_print) + assert "Detected shell: fish" in out + assert "register-python-argcomplete --shell fish struct" in out + assert "~/.config/fish/completions/struct.fish" in out + + +def test_completion_install_auto_detect_zsh(): + parser = make_parser() + cmd = CompletionCommand(parser) + with patch.dict(os.environ, {"SHELL": "/bin/zsh"}, clear=False): + with patch('builtins.print') as mock_print: + args = parser.parse_args(['install']) + cmd._install(args) + out = _gather_print_output(mock_print) + assert "Detected shell: zsh" in out + assert "register-python-argcomplete --shell zsh struct" in out diff --git a/tests/test_init_command.py b/tests/test_init_command.py new file mode 100644 index 0000000..37bd103 --- /dev/null +++ b/tests/test_init_command.py @@ -0,0 +1,48 @@ +import argparse +import os +from unittest.mock import patch + +from struct_module.commands.init import InitCommand, BASIC_STRUCT_YAML + + +def test_init_creates_struct_yaml(tmp_path): + parser = argparse.ArgumentParser() + cmd = InitCommand(parser) + + target_dir = tmp_path / "proj" + args = parser.parse_args([str(target_dir)]) + + with patch('builtins.print') as mock_print: + cmd.execute(args) + + struct_file = target_dir / '.struct.yaml' + assert struct_file.exists() + + content = struct_file.read_text() + # Basic checks for key sections + assert 'pre_hooks:' in content + assert 'post_hooks:' in content + assert 'files:' in content + assert 'README.md' in content + assert 'folders:' in content + assert 'github/workflows/run-struct' in content + + +def test_init_skips_if_exists(tmp_path): + parser = argparse.ArgumentParser() + cmd = InitCommand(parser) + + target_dir = tmp_path / "proj" + target_dir.mkdir(parents=True) + existing = target_dir / '.struct.yaml' + existing.write_text('files: []\n') + + args = parser.parse_args([str(target_dir)]) + + with patch('builtins.print') as mock_print: + cmd.execute(args) + # Should not overwrite existing file + assert existing.read_text() == 'files: []\n' + # Should print a message about skipping + printed = "\n".join(c.args[0] for c in mock_print.call_args_list) + assert '.struct.yaml already exists' in printed