From 6ff6ceb6897403e905429e9adce12f03b0058dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Sun, 31 May 2026 11:25:07 +0800 Subject: [PATCH 1/3] fix: respect explicit click names in skill generation --- cli-anything-plugin/skill_generator.py | 64 ++++++++++++------- .../tests/test_skill_generator.py | 40 ++++++++++++ 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index 80aeb53cf..07b913318 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -30,6 +30,16 @@ def _canonical_skill_name(harness_path: Path, software_name: str) -> str: return f"cli-anything-{software_dir.replace('_', '-')}" +def _click_declared_name(decorator_args: str, fallback: str) -> str: + """Return the Click command/group name declared in a decorator.""" + match = re.search(r'^\s*["\']([^"\']+)["\']', decorator_args) + if not match: + match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_args) + if match: + return match.group(1) + return fallback.replace("_", "-") + + @dataclass class CommandInfo: """Information about a CLI command.""" @@ -210,27 +220,29 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: # - Various Click decorator patterns like @click.option(), @click.argument() # Uses re.DOTALL to match across newlines between decorator and def group_pattern = ( - r'@(\w+)\.group\([^)]*\)' # @xxx.group(...) + r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...) r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): r':\s*' # colon with optional whitespace r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''') ) + group_lookup = {} for match in re.finditer(group_pattern, content): - group_func = match.group(2) - # Docstring can be in group 3 (triple-double) or group 4 (triple-single) - group_doc = (match.group(3) or match.group(4) or "").strip() + group_args = match.group(2) + group_func = match.group(3) + # Docstring can be in group 4 (triple-double) or group 5 (triple-single) + group_doc = (match.group(4) or match.group(5) or "").strip() - group_name = group_func.replace("_", " ").title() - if not group_name: - group_name = group_func.title() + group_name = _format_display_name(_click_declared_name(group_args, group_func)) - groups.append(CommandGroup( + group = CommandGroup( name=group_name, description=group_doc or f"Commands for {group_name.lower()} operations.", commands=[] - )) + ) + groups.append(group) + group_lookup[group_func.lower()] = group # Find Click command decorators # Pattern handles: @@ -238,7 +250,7 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: # - Docstrings on the same line or following line after function definition # - Various Click decorator patterns like @click.option(), @click.argument() command_pattern = ( - r'@(\w+)\.command\([^)]*\)' # @xxx.command(...) + r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...) r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): r':\s*' # colon with optional whitespace @@ -247,17 +259,19 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: for match in re.finditer(command_pattern, content): group_name = match.group(1) - cmd_name = match.group(2) - # Docstring can be in group 3 (triple-double) or group 4 (triple-single) - cmd_doc = (match.group(3) or match.group(4) or "").strip() + cmd_args = match.group(2) + cmd_func = match.group(3) + # Docstring can be in group 4 (triple-double) or group 5 (triple-single) + cmd_doc = (match.group(4) or match.group(5) or "").strip() # Find the matching group - for group in groups: - if group.name.lower().replace(" ", "_") == group_name.lower(): - group.commands.append(CommandInfo( - name=cmd_name.replace("_", "-"), - description=cmd_doc or f"Execute {cmd_name} operation." - )) + group = group_lookup.get(group_name.lower()) + if group: + cmd_name = _click_declared_name(cmd_args, cmd_func) + group.commands.append(CommandInfo( + name=cmd_name, + description=cmd_doc or f"Execute {cmd_func} operation." + )) # If no groups found, create a default one with all commands if not groups: @@ -268,12 +282,14 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: ) for match in re.finditer(command_pattern, content): - cmd_name = match.group(2) - # Docstring can be in group 3 (triple-double) or group 4 (triple-single) - cmd_doc = (match.group(3) or match.group(4) or "").strip() + cmd_args = match.group(2) + cmd_func = match.group(3) + # Docstring can be in group 4 (triple-double) or group 5 (triple-single) + cmd_doc = (match.group(4) or match.group(5) or "").strip() + cmd_name = _click_declared_name(cmd_args, cmd_func) default_group.commands.append(CommandInfo( - name=cmd_name.replace("_", "-"), - description=cmd_doc or f"Execute {cmd_name} operation." + name=cmd_name, + description=cmd_doc or f"Execute {cmd_func} operation." )) if default_group.commands: diff --git a/cli-anything-plugin/tests/test_skill_generator.py b/cli-anything-plugin/tests/test_skill_generator.py index c2f7216d5..35ea4d4b3 100644 --- a/cli-anything-plugin/tests/test_skill_generator.py +++ b/cli-anything-plugin/tests/test_skill_generator.py @@ -143,6 +143,46 @@ class TestExtractCliMetadata: assert "export" in cmd_names assert "import-data" in cmd_names + def test_respects_explicit_click_names(self, tmp_path): + software = "named" + cli_pkg = tmp_path / "cli_anything" / software + cli_pkg.mkdir(parents=True) + (cli_pkg / "__init__.py").write_text("") + (cli_pkg / f"{software}_cli.py").write_text( + textwrap.dedent("""\ + import click + + @click.group() + def cli(): + pass + + @cli.group("remote-access") + def remote_access_group(): + \"\"\"Remote access commands.\"\"\" + pass + + @remote_access_group.command("list-active") + def list_active_sessions(): + \"\"\"List active sessions.\"\"\" + pass + + @cli.command(name="health-check") + def health(): + \"\"\"Check service health.\"\"\" + pass + """) + ) + + metadata = extract_cli_metadata(str(tmp_path)) + groups = {group.name: group for group in metadata.command_groups} + + assert "Remote Access" in groups + assert "Remote Access Group" not in groups + assert [cmd.name for cmd in groups["Remote Access"].commands] == ["list-active"] + cli_commands = [cmd.name for cmd in groups["Cli"].commands] + assert "health-check" in cli_commands + assert "health" not in cli_commands + def test_generates_examples(self, harness_dir): metadata = extract_cli_metadata(str(harness_dir)) assert len(metadata.examples) > 0 From 259a534e676596f03c632e30f2af7b938b1cdd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Mon, 1 Jun 2026 10:44:49 +0800 Subject: [PATCH 2/3] fix(review): disambiguate nested Click groups on PR #321 --- cli-anything-plugin/skill_generator.py | 25 +++++++++- .../tests/test_skill_generator.py | 49 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index 07b913318..8d5dbf95f 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -228,13 +228,33 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: ) group_lookup = {} - for match in re.finditer(group_pattern, content): + group_display_paths = {} + group_matches = list(re.finditer(group_pattern, content)) + root_group_funcs = { + match.group(3).lower() + for match in group_matches + if match.group(1).lower() == "click" + } + + for match in group_matches: + group_parent = match.group(1) group_args = match.group(2) group_func = match.group(3) # Docstring can be in group 4 (triple-double) or group 5 (triple-single) group_doc = (match.group(4) or match.group(5) or "").strip() - group_name = _format_display_name(_click_declared_name(group_args, group_func)) + local_group_name = _format_display_name(_click_declared_name(group_args, group_func)) + parent_key = group_parent.lower() + if parent_key == "click" or parent_key in root_group_funcs: + group_path = [local_group_name] + else: + parent_path = group_display_paths.get(parent_key) + if parent_path: + group_path = [*parent_path, local_group_name] + else: + group_path = [_format_display_name(group_parent), local_group_name] + + group_name = " ".join(group_path) group = CommandGroup( name=group_name, @@ -243,6 +263,7 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: ) groups.append(group) group_lookup[group_func.lower()] = group + group_display_paths[group_func.lower()] = group_path # Find Click command decorators # Pattern handles: diff --git a/cli-anything-plugin/tests/test_skill_generator.py b/cli-anything-plugin/tests/test_skill_generator.py index 35ea4d4b3..80d78c910 100644 --- a/cli-anything-plugin/tests/test_skill_generator.py +++ b/cli-anything-plugin/tests/test_skill_generator.py @@ -183,6 +183,55 @@ class TestExtractCliMetadata: assert "health-check" in cli_commands assert "health" not in cli_commands + def test_disambiguates_nested_declared_group_names(self, tmp_path): + software = "nested" + cli_pkg = tmp_path / "cli_anything" / software + cli_pkg.mkdir(parents=True) + (cli_pkg / "__init__.py").write_text("") + (cli_pkg / f"{software}_cli.py").write_text( + textwrap.dedent("""\ + import click + + @click.group() + def cli(): + pass + + @cli.group("configs") + def configs_group(): + \"\"\"Top-level config commands.\"\"\" + pass + + @configs_group.command("show") + def show_config(): + \"\"\"Show config.\"\"\" + pass + + @cli.group("alerts") + def alerts(): + \"\"\"Alert commands.\"\"\" + pass + + @alerts.group("configs") + def alerts_configs(): + \"\"\"Alert config commands.\"\"\" + pass + + @alerts_configs.command("enable") + def enable_alert_config(): + \"\"\"Enable alert config.\"\"\" + pass + """) + ) + + metadata = extract_cli_metadata(str(tmp_path)) + groups = {group.name: group for group in metadata.command_groups} + group_names = [group.name for group in metadata.command_groups] + + assert group_names.count("Configs") == 1 + assert "Alerts Configs" in groups + assert [cmd.name for cmd in groups["Configs"].commands] == ["show"] + assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"] + def test_generates_examples(self, harness_dir): metadata = extract_cli_metadata(str(harness_dir)) assert len(metadata.examples) > 0 From 88ef85726ae82833f3a9427f591cf098f75d17de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Thu, 4 Jun 2026 15:20:30 +0800 Subject: [PATCH 3/3] fix(review): detect decorated Click root groups --- cli-anything-plugin/skill_generator.py | 22 ++++---- .../tests/test_skill_generator.py | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index 8d5dbf95f..1f754fadf 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -219,12 +219,14 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: # - Docstrings on the same line or following line after function definition # - Various Click decorator patterns like @click.option(), @click.argument() # Uses re.DOTALL to match across newlines between decorator and def + optional_decorator_pattern = r'(?:\s*@[\w.]+(?:\([^)]*\))?)*' + group_pattern = ( - r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...) - r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators - r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): - r':\s*' # colon with optional whitespace - r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''') + r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...) + + optional_decorator_pattern # optional additional decorators + + r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): + + r':\s*' # colon with optional whitespace + + r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''') ) group_lookup = {} @@ -271,11 +273,11 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]: # - Docstrings on the same line or following line after function definition # - Various Click decorator patterns like @click.option(), @click.argument() command_pattern = ( - r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...) - r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators - r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): - r':\s*' # colon with optional whitespace - r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''') + r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...) + + optional_decorator_pattern # optional additional decorators + + r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...): + + r':\s*' # colon with optional whitespace + + r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''') ) for match in re.finditer(command_pattern, content): diff --git a/cli-anything-plugin/tests/test_skill_generator.py b/cli-anything-plugin/tests/test_skill_generator.py index 80d78c910..c389273b4 100644 --- a/cli-anything-plugin/tests/test_skill_generator.py +++ b/cli-anything-plugin/tests/test_skill_generator.py @@ -232,6 +232,56 @@ class TestExtractCliMetadata: assert [cmd.name for cmd in groups["Configs"].commands] == ["show"] assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"] + def test_preserves_top_level_group_names_with_decorated_root(self, tmp_path): + software = "decorated" + cli_pkg = tmp_path / "cli_anything" / software + cli_pkg.mkdir(parents=True) + (cli_pkg / "__init__.py").write_text("") + (cli_pkg / f"{software}_cli.py").write_text( + textwrap.dedent("""\ + import click + + @click.group() + @click.pass_context + def cli(ctx): + pass + + @cli.group("devices") + def devices_group(): + \"\"\"Device commands.\"\"\" + pass + + @devices_group.command("list") + def list_devices(): + \"\"\"List devices.\"\"\" + pass + + @cli.group("alerts") + def alerts(): + \"\"\"Alert commands.\"\"\" + pass + + @alerts.group("configs") + def alerts_configs(): + \"\"\"Alert config commands.\"\"\" + pass + + @alerts_configs.command("enable") + def enable_alert_config(): + \"\"\"Enable alert config.\"\"\" + pass + """) + ) + + metadata = extract_cli_metadata(str(tmp_path)) + groups = {group.name: group for group in metadata.command_groups} + + assert "Devices" in groups + assert "Cli Devices" not in groups + assert "Alerts Configs" in groups + assert [cmd.name for cmd in groups["Devices"].commands] == ["list"] + assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"] + def test_generates_examples(self, harness_dir): metadata = extract_cli_metadata(str(harness_dir)) assert len(metadata.examples) > 0