diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 79ada10..af91bd1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -117,7 +117,7 @@ Usage: 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. +- If no shell is provided, the command attempts to auto-detect your current shell and prints the exact commands to generate and install static completion files via shtab. - This does not modify your shell configuration; it only prints the commands you can copy-paste. ### `init` diff --git a/docs/completion.md b/docs/completion.md index a8cb7f8..d53814c 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -1,13 +1,13 @@ # Command-Line Auto-Completion -STRUCT provides intelligent auto-completion for commands, options, and **structure names** using [argcomplete](https://kislyuk.github.io/argcomplete/). This makes discovering and using available structures much faster and more user-friendly. +STRUCT provides intelligent auto-completion for commands, options, and structure names using static completion scripts generated by [shtab](https://github.com/Iterative/shtab). This approach is reliable across shells and doesn’t require runtime hooks or markers. -!!! tip "New Feature: Structure Name Completion" - STRUCT now automatically completes structure names when using `struct generate`, showing all 47+ available structures from both built-in and custom paths! +!!! tip "Structure Name Completion" + STRUCT completes structure names when using `struct generate`, showing available structures from both built-in and custom paths. ## Quick Setup -The easiest way is to ask struct to print the exact commands for your shell: +Ask struct to print the exact commands for your shell: ```sh # Auto-detect current shell and print install steps @@ -19,77 +19,48 @@ struct completion install bash struct completion install fish ``` -You can still follow the manual steps below if you prefer. +You can also generate completion files manually with shtab as shown below. -For most users, this simple setup will enable full completion: +## Manual Installation -```sh -# Install (if not already installed) -pip install argcomplete - -# Enable completion for current session -eval "$(register-python-argcomplete struct)" - -# Make permanent - add to your ~/.zshrc or ~/.bashrc -echo 'eval "$(register-python-argcomplete struct)"' >> ~/.zshrc -``` - -## Detailed Installation - -### 1. Install argcomplete +### 1) Install shtab ```sh -pip install argcomplete +pip install shtab ``` -### 2. Enable Global Completion (Optional) +### 2) Generate and install completion for your shell -This step is optional but can be done once per system: +- Zsh -```sh -activate-global-python-argcomplete -``` - -This command sets up global completion for all Python scripts that use argcomplete. + ```sh + mkdir -p ~/.zfunc + struct --print-completion zsh > ~/.zfunc/_struct + # ensure in ~/.zshrc + fpath=(~/.zfunc $fpath) + autoload -U compinit && compinit + exec zsh + ``` -### 3. Register the Script +- Bash -Add the following line to your shell's configuration file: + ```sh + mkdir -p ~/.local/share/bash-completion/completions + struct --print-completion bash > ~/.local/share/bash-completion/completions/struct + source ~/.bashrc + ``` -**For Bash** (`.bashrc` or `.bash_profile`): +- Fish -```sh -eval "$(register-python-argcomplete struct)" -``` - -**For Zsh** (`.zshrc`): - -```sh -eval "$(register-python-argcomplete struct)" -``` - -**For Fish** (`.config/fish/config.fish`): - -```fish -register-python-argcomplete --shell fish struct | source -``` - -### 4. Reload Your Shell - -```sh -# For Bash -source ~/.bashrc - -# For Zsh -source ~/.zshrc - -# For Fish -source ~/.config/fish/config.fish -``` + ```sh + mkdir -p ~/.config/fish/completions + struct --print-completion fish > ~/.config/fish/completions/struct.fish + fish -c 'source ~/.config/fish/completions/struct.fish' + ``` ## Usage -After completing the setup, you can use auto-completion by typing part of a command and pressing `Tab`: +After installing the completion, use Tab to complete commands/options: ### Command Completion ```sh @@ -132,12 +103,7 @@ struct generate --log ### Per-Project Completion -If you only want completion for specific projects, you can add completion to your project's virtual environment activation script: - -```sh -# In your .venv/bin/activate file, add: -eval "$(register-python-argcomplete struct)" -``` +If you only want completion for a specific project/venv, generate the completion from the project’s venv and place it under your user completion directory (examples above). No runtime eval is needed. ### Custom Completion @@ -158,60 +124,41 @@ complete -F _struct_structures struct-generate ### Completion Not Working -1. **Check argcomplete installation**: - - ```sh - python -c "import argcomplete; print('OK')" - ``` - -2. **Verify global activation**: +1. Verify shtab is installed in the environment you’re using: ```sh - activate-global-python-argcomplete --user + python -c "import shtab; print('OK')" ``` -3. **Check shell configuration**: - Make sure the eval statement is in the correct shell configuration file. +2. Confirm the completion file exists in the expected location and is readable. -4. **Restart your shell**: - Sometimes you need to completely restart your terminal. +3. Ensure your shell is configured to load completions: + - zsh: fpath includes ~/.zfunc and compinit is run. + - bash: bash-completion is installed and sourced (on some distros). + - fish: the file is in ~/.config/fish/completions/. -### Slow Completion - -If completion is slow, you can enable caching: - -```sh -export ARGCOMPLETE_USE_TEMPFILES=1 -``` - -Add this to your shell configuration file for persistent caching. +4. Restart your shell (or run `exec zsh`/`source ~/.bashrc`). ### Debug Completion -Enable debug mode to troubleshoot completion issues: - -```sh -export _ARGCOMPLETE_DEBUG=1 -struct -``` +For shell-specific debugging, check that the generated file contains the struct completion function and is in the correct directory for your shell. ## Platform-Specific Notes ### macOS -On macOS, you might need to install bash-completion first: +On macOS, you may need to install bash-completion (for bash) or ensure zsh’s compinit is configured: ```sh # Using Homebrew brew install bash-completion - -# Then add to ~/.bash_profile: +# bash profile [[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]] && . "/usr/local/etc/profile.d/bash_completion.sh" ``` ### Windows -For Windows users using Git Bash or WSL, follow the same steps as Linux. For PowerShell, argcomplete support is limited. +For Windows users using Git Bash or WSL, follow the same steps as Linux. PowerShell is not covered by shtab; use bash/zsh/fish. ### Docker diff --git a/docs/installation.md b/docs/installation.md index 8de341d..e822d8c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -9,11 +9,7 @@ pip install git+https://github.com/httpdss/struct.git ``` !!! tip "Enable Auto-Completion" - After installation, enable command-line auto-completion for better productivity: - ```sh - eval "$(register-python-argcomplete struct)" - ``` - For permanent setup, see the [Command-Line Completion](completion.md) guide. + After installation, enable command-line auto-completion using static scripts generated by shtab. See the [Command-Line Completion](completion.md) guide for per-shell instructions. ## From Source diff --git a/requirements.txt b/requirements.txt index 35527b5..1b1be23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ openai python-dotenv jinja2 PyGithub -argcomplete +shtab colorlog boto3 google-cloud diff --git a/struct_module/commands/completion.py b/struct_module/commands/completion.py index cb7053b..43efbb6 100644 --- a/struct_module/commands/completion.py +++ b/struct_module/commands/completion.py @@ -6,7 +6,7 @@ class CompletionCommand(Command): def __init__(self, parser): super().__init__(parser) - parser.description = "Manage CLI shell completions for struct (argcomplete)" + parser.description = "Manage CLI shell completions for struct (shtab-generated)" sub = parser.add_subparsers(dest="action") install = sub.add_parser("install", help="Print the commands to enable completion for your shell") @@ -29,27 +29,32 @@ def _install(self, args): 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("\n# Install shtab (once, in your environment):") + print("python -m pip install shtab") + print("\n# Generate static bash completion for 'struct':") + print("mkdir -p ~/.local/share/bash-completion/completions") + print("struct --print-completion bash > ~/.local/share/bash-completion/completions/struct") + print("\n# Apply now (or open a new shell):") 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") + print("\n# Install shtab (once, in your environment):") + print("python -m pip install shtab") + print("\n# Generate static zsh completion for 'struct':") + print("mkdir -p ~/.zfunc") + print("struct --print-completion zsh > ~/.zfunc/_struct") + print("\n# Ensure zsh loads user functions/completions (append to ~/.zshrc if needed):") + print('echo "fpath=(~/.zfunc $fpath)" >> ~/.zshrc') + print('echo "autoload -U compinit && compinit" >> ~/.zshrc') + print("\n# Apply now (or open a new shell):") + print("exec zsh") 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("\n# Install shtab (once, in your environment):") + print("python -m pip install shtab") + print("\n# Generate static fish completion for 'struct':") print('mkdir -p ~/.config/fish/completions') - print('register-python-argcomplete --shell fish struct > ~/.config/fish/completions/struct.fish') + print('struct --print-completion fish > ~/.config/fish/completions/struct.fish') print("\n# Apply now:") print("fish -c 'source ~/.config/fish/completions/struct.fish'") @@ -57,4 +62,4 @@ def _install(self, args): 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 ") + print("\nTip: You can also print completion directly via: struct --print-completion ") diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py index 3d9f1f1..2a078d3 100644 --- a/struct_module/commands/generate.py +++ b/struct_module/commands/generate.py @@ -12,6 +12,7 @@ class GenerateCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "Generate the project structure from a YAML configuration file" structure_arg = parser.add_argument('structure_definition', type=str, help='Path to the YAML configuration file') structure_arg.completer = structures_completer parser.add_argument('base_path', type=str, help='Base path where the structure will be created') @@ -30,6 +31,33 @@ def __init__(self, parser): choices=['console', 'file'], default='file', help='Output mode') parser.set_defaults(func=self.execute) + def _parse_template_vars(self, vars_str): + """Parse a comma-separated KEY=VALUE string into a dict safely. + - Ignores empty tokens and trailing commas + - Supports values containing '=' by splitting only on the first '=' + - Logs and skips malformed entries without raising + """ + result = {} + if not vars_str: + return result + # Normalize by removing accidental leading/trailing commas and whitespace + tokens = [t.strip() for t in vars_str.strip(', ').split(',')] + for token in tokens: + if not token: + continue + if '=' not in token: + # Skip malformed item but warn + self.logger.warning(f"Skipping malformed template var (no '='): '{token}'") + continue + key, value = token.split('=', 1) + key = key.strip() + value = value + if not key: + self.logger.warning(f"Skipping template var with empty key: '{token}'") + continue + result[key] = value + return result + def _deep_merge_dicts(self, dict1, dict2): """ Deep merge two dictionaries, with dict2 values overriding dict1 values. @@ -146,7 +174,8 @@ def _create_structure(self, args, mappings=None, summary=None, print_summary=Tru if config is None: return summary if summary is not None else None - template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None + # Safely parse template variables + template_vars = self._parse_template_vars(args.vars) if getattr(args, 'vars', None) else {} config_structure = config.get('files', config.get('structure', [])) config_folders = config.get('folders', []) config_variables = config.get('variables', []) @@ -301,8 +330,18 @@ def _create_structure(self, args, mappings=None, summary=None, print_summary=Tru merged_vars = ",".join( [f"{k}={v}" for k, v in rendered_with.items()]) - if args.vars: - merged_vars = args.vars + "," + merged_vars + # Merge parent args.vars safely without introducing trailing commas + if getattr(args, 'vars', None): + parts = [] + parent_vars = args.vars.strip().strip(',') + if parent_vars: + parts.append(parent_vars) + if merged_vars: + parts.append(merged_vars) + merged_vars = ",".join(parts) + + # If nothing to merge, keep None to avoid accidental truthiness with empty string + merged_vars = merged_vars if merged_vars else None if isinstance(content['struct'], str): self._create_structure({ @@ -345,8 +384,8 @@ def _create_structure(self, args, mappings=None, summary=None, print_summary=Tru self.logger.info(f" ✅ Created: {summary['created']}") self.logger.info(f" ✅ Updated: {summary['updated']}") self.logger.info(f" 📝 Appended: {summary['appended']}") - self.logger.info(f" ⏭️ Skipped: {summary['skipped']}") - self.logger.info(f" 🗄️ Backed up: {summary['backed_up']}") + self.logger.info(f" ⏭️ Skipped: {summary['skipped']}") + self.logger.info(f" 🗄️ Backed up: {summary['backed_up']}") self.logger.info(f" 🔁 Renamed: {summary['renamed']}") self.logger.info(f" 📁 Folders created: {summary['folders']}") if args.dry_run: diff --git a/struct_module/commands/generate_schema.py b/struct_module/commands/generate_schema.py index 3e3fb4e..f04421a 100644 --- a/struct_module/commands/generate_schema.py +++ b/struct_module/commands/generate_schema.py @@ -6,6 +6,7 @@ class GenerateSchemaCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "Generate JSON schema for available structures" parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions') parser.add_argument('-o', '--output', type=str, help='Output file path for the schema (default: stdout)') parser.set_defaults(func=self.execute) diff --git a/struct_module/commands/info.py b/struct_module/commands/info.py index 8a6569a..9b36777 100644 --- a/struct_module/commands/info.py +++ b/struct_module/commands/info.py @@ -8,6 +8,7 @@ class InfoCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "Show information about the package or structure definition" parser.add_argument('structure_definition', type=str, help='Name of the structure definition') parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions') parser.add_argument('--mcp', action='store_true', help='Enable MCP (Model Context Protocol) integration') diff --git a/struct_module/commands/list.py b/struct_module/commands/list.py index 3a60bbf..e55ebc7 100644 --- a/struct_module/commands/list.py +++ b/struct_module/commands/list.py @@ -9,7 +9,9 @@ class ListCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "List available structures" parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions') + parser.add_argument('--names-only', action='store_true', help='Print only structure names, one per line (for shell completion)') parser.add_argument('--mcp', action='store_true', help='Enable MCP (Model Context Protocol) integration') parser.set_defaults(func=self.execute) @@ -30,7 +32,6 @@ def _list_structures(self, args): else: paths_to_list = [contribs_path] - print("📃 Listing available structures\n") all_structures = set() for path in paths_to_list: for root, _, files in os.walk(path): @@ -39,11 +40,23 @@ def _list_structures(self, args): rel_path = os.path.relpath(file_path, path) if file.endswith(".yaml"): rel_path = rel_path[:-5] - if path != contribs_path: + # Mark custom path entries with '+ ' unless names-only requested + if not args.names_only and path != contribs_path: rel_path = f"+ {rel_path}" all_structures.add(rel_path) sorted_list = sorted(all_structures) + + if args.names_only: + # Print plain names without bullets or headers, remove '+ ' marker + for structure in sorted_list: + if structure.startswith('+ '): + print(structure[2:]) + else: + print(structure) + return + + print("📃 Listing available structures\n") for structure in sorted_list: print(f" - {structure}") diff --git a/struct_module/commands/mcp.py b/struct_module/commands/mcp.py index a44eeac..b194718 100644 --- a/struct_module/commands/mcp.py +++ b/struct_module/commands/mcp.py @@ -8,6 +8,7 @@ class MCPCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "MCP (Model Context Protocol) support for struct tool" parser.add_argument('--server', action='store_true', help='Start the MCP server for stdio communication') parser.set_defaults(func=self.execute) diff --git a/struct_module/commands/validate.py b/struct_module/commands/validate.py index 15f7e38..4c3361a 100644 --- a/struct_module/commands/validate.py +++ b/struct_module/commands/validate.py @@ -9,6 +9,7 @@ class ValidateCommand(Command): def __init__(self, parser): super().__init__(parser) + parser.description = "Validate a YAML configuration file for structure definitions" parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file') parser.set_defaults(func=self.execute) diff --git a/struct_module/main.py b/struct_module/main.py index 5161542..d2fe2d2 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -1,4 +1,4 @@ -import argparse, argcomplete +import argparse import logging from dotenv import load_dotenv from struct_module.utils import read_config_file, merge_configs @@ -10,11 +10,15 @@ from struct_module.commands.mcp import MCPCommand from struct_module.logging_config import configure_logging - +# Optional dependency: shtab for static shell completion generation +try: + import shtab # type: ignore +except Exception: # pragma: no cover - optional at runtime + shtab = None load_dotenv() -def main(): +def get_parser(): parser = argparse.ArgumentParser( description="Generate project structure from YAML configuration.", prog="struct", @@ -35,11 +39,19 @@ def main(): from struct_module.commands.init import InitCommand InitCommand(subparsers.add_parser('init', help='Initialize a basic .struct.yaml in the target directory')) - # completion installer + # completion manager from struct_module.commands.completion import CompletionCommand CompletionCommand(subparsers.add_parser('completion', help='Manage shell completions')) - argcomplete.autocomplete(parser) + # Add shtab completion printing flags if available + if shtab is not None: + # Adds --print-completion and --shell flags + shtab.add_argument_to(parser) + + return parser + +def main(): + parser = get_parser() args = parser.parse_args() @@ -49,14 +61,13 @@ def main(): parser.exit() # Read config file if provided - if args.config_file: + if getattr(args, 'config_file', None): file_config = read_config_file(args.config_file) args = argparse.Namespace(**merge_configs(file_config, args)) - logging_level = getattr(logging, args.log.upper(), logging.INFO) - - configure_logging(level=logging_level, log_file=args.log_file) + logging_level = getattr(logging, getattr(args, 'log', 'INFO').upper(), logging.INFO) + configure_logging(level=logging_level, log_file=getattr(args, 'log_file', None)) args.func(args) diff --git a/tests/test_completion_command.py b/tests/test_completion_command.py index 43a68a0..d7d9692 100644 --- a/tests/test_completion_command.py +++ b/tests/test_completion_command.py @@ -21,8 +21,8 @@ def test_completion_install_bash_explicit(): 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 + assert "struct --print-completion bash" in out + assert "~/.local/share/bash-completion/completions/struct" in out def test_completion_install_zsh_explicit(): @@ -33,8 +33,8 @@ def test_completion_install_zsh_explicit(): 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 + assert "struct --print-completion zsh" in out + assert "~/.zfunc/_struct" in out def test_completion_install_fish_explicit(): @@ -45,7 +45,7 @@ def test_completion_install_fish_explicit(): 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 "struct --print-completion fish" in out assert "~/.config/fish/completions/struct.fish" in out @@ -58,4 +58,4 @@ def test_completion_install_auto_detect_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 "struct --print-completion zsh" in out