diff --git a/src/commands.py b/src/commands.py index b327977a..fde3d401 100644 --- a/src/commands.py +++ b/src/commands.py @@ -35,6 +35,11 @@ def load_command_snapshot() -> tuple[PortingModule, ...]: PORTED_COMMANDS = load_command_snapshot() +COMMAND_ALIASES = { + 'plugins': 'plugin', + 'marketplace': 'plugin', +} + @lru_cache(maxsize=1) def built_in_command_names() -> frozenset[str]: @@ -50,7 +55,7 @@ def command_names() -> list[str]: def get_command(name: str) -> PortingModule | None: - needle = name.lower() + needle = COMMAND_ALIASES.get(name.lower(), name.lower()) for module in PORTED_COMMANDS: if module.name.lower() == needle: return module diff --git a/src/runtime.py b/src/runtime.py index c4116b7a..5fbd9538 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from .commands import PORTED_COMMANDS +from .commands import PORTED_COMMANDS, get_command from .context import PortContext, build_port_context, render_context from .history import HistoryLog from .models import PermissionDenial, PortingModule @@ -88,6 +88,7 @@ class RuntimeSession: class PortRuntime: def route_prompt(self, prompt: str, limit: int = 5) -> list[RoutedMatch]: + explicit_command = self._explicit_command_match(prompt) tokens = {token.lower() for token in prompt.replace('/', ' ').replace('-', ' ').split() if token} by_kind = { 'command': self._collect_matches(tokens, PORTED_COMMANDS, 'command'), @@ -95,6 +96,16 @@ class PortRuntime: } selected: list[RoutedMatch] = [] + if explicit_command is not None: + selected.append(explicit_command) + by_kind['command'] = [ + match + for match in by_kind['command'] + if not ( + match.name == explicit_command.name + and match.source_hint == explicit_command.source_hint + ) + ] for kind in ('command', 'tool'): if by_kind[kind]: selected.append(by_kind[kind].pop(0)) @@ -106,6 +117,22 @@ class PortRuntime: selected.extend(leftovers[: max(0, limit - len(selected))]) return selected[:limit] + @staticmethod + def _explicit_command_match(prompt: str) -> RoutedMatch | None: + first_token = prompt.strip().split(maxsplit=1)[0] if prompt.strip() else '' + command_name = first_token.removeprefix('/') + if not command_name: + return None + module = get_command(command_name) + if module is None: + return None + return RoutedMatch( + kind='command', + name=module.name, + source_hint=module.source_hint, + score=100, + ) + def bootstrap_session(self, prompt: str, limit: int = 5) -> RuntimeSession: context = build_port_context() setup_report = run_setup(trusted=True) diff --git a/tests/test_porting_workspace.py b/tests/test_porting_workspace.py index b332467d..0ff1e258 100644 --- a/tests/test_porting_workspace.py +++ b/tests/test_porting_workspace.py @@ -159,6 +159,80 @@ class PortingWorkspaceTests(unittest.TestCase): self.assertIn('Command entries:', command_result.stdout) self.assertIn('Tool entries:', tool_result.stdout) + def test_plugin_command_filter_excludes_plugin_sources(self) -> None: + from src.commands import get_commands + + all_commands = get_commands() + filtered_commands = get_commands(include_plugin_commands=False) + + self.assertGreater(len(all_commands), len(filtered_commands)) + self.assertFalse( + any('plugin' in command.source_hint.lower() for command in filtered_commands) + ) + + def test_plugin_command_aliases_execute_as_local_commands(self) -> None: + for alias in ('plugin', 'plugins', 'marketplace'): + with self.subTest(alias=alias): + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'exec-command', alias, f'{alias} list'], + check=True, + capture_output=True, + text=True, + ) + + self.assertIn("Mirrored command 'plugin'", result.stdout) + self.assertNotIn('Unknown mirrored command', result.stdout) + + def test_route_plugin_slash_commands_match_commands(self) -> None: + prompts = ('/plugin list', '/plugins list', '/marketplace browse', '/reload-plugins') + for prompt in prompts: + with self.subTest(prompt=prompt): + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'route', prompt, '--limit', '5'], + check=True, + capture_output=True, + text=True, + ) + + first_line = result.stdout.splitlines()[0] + self.assertTrue(first_line.startswith('command\t'), result.stdout) + self.assertRegex(first_line, r'\t(plugin|reload-plugins)\t') + + def test_plugin_command_stream_emits_command_match(self) -> None: + from src.runtime import PortRuntime + + for prompt in ('/plugin list', '/plugins list', '/marketplace browse', '/reload-plugins'): + with self.subTest(prompt=prompt): + session = PortRuntime().bootstrap_session(prompt, limit=5) + command_events = [ + event for event in session.stream_events if event['type'] == 'command_match' + ] + + self.assertTrue(command_events, session.as_markdown()) + self.assertNotIn('Matched commands: none', session.turn_result.output) + + def test_turn_loop_plugin_commands_are_not_prompt_only(self) -> None: + for prompt in ('/plugin list', '/plugins list', '/marketplace browse', '/reload-plugins'): + with self.subTest(prompt=prompt): + result = subprocess.run( + [ + sys.executable, + '-m', + 'src.main', + 'turn-loop', + prompt, + '--max-turns', + '1', + '--structured-output', + ], + check=True, + capture_output=True, + text=True, + ) + + self.assertIn('"Matched commands:', result.stdout) + self.assertNotIn('Matched commands: none', result.stdout) + def test_load_session_cli_runs(self) -> None: from src.runtime import PortRuntime