Skip to content

Commit a2ba62a

Browse files
Add validated command catalog facade
1 parent ec9f37e commit a2ba62a

7 files changed

Lines changed: 226 additions & 15 deletions

File tree

mythic_vibe_cli/commands.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@
4141
from .plugins.dispatcher import PluginHookDispatcher
4242
from .plugins.loader import inspect_plugin
4343
from .plugins.registry import PluginRegistry
44-
from .runtime.slash_commands import BUILTIN_SLASH_COMMANDS, BuiltinSlashCommand, SlashCommandInfo
44+
from .runtime.command_catalog import (
45+
SLASH_LOCAL_NAMES,
46+
builtin_slash_by_name,
47+
iter_builtin_slash_commands,
48+
)
49+
from .runtime.slash_commands import BuiltinSlashCommand, SlashCommandInfo
4550
from .core.state import PHASES, VerificationRecord, coerce_project_state, utc_now, validate_state_payload
4651
from .persistence.json_store import JsonStateStore, StateStoreError
4752
from .persistence.migrations import migrate_project_state
@@ -5993,7 +5998,8 @@ def cmd_slash_list(args: argparse.Namespace) -> int:
59935998
root = Path(args.path).resolve()
59945999
source_filter = (getattr(args, "source", "") or "").strip().lower()
59956000

5996-
builtin_payload = [entry.to_dict() for entry in BUILTIN_SLASH_COMMANDS]
6001+
builtins = iter_builtin_slash_commands()
6002+
builtin_payload = [entry.to_dict() for entry in builtins]
59976003

59986004
contributed: list[SlashCommandInfo] = []
59996005
if source_filter != "builtin":
@@ -6020,7 +6026,7 @@ def cmd_slash_list(args: argparse.Namespace) -> int:
60206026

60216027
if not source_filter or source_filter == "builtin":
60226028
write_line("Builtin slash commands:")
6023-
for entry in BUILTIN_SLASH_COMMANDS:
6029+
for entry in builtins:
60246030
write_bullet(f"/{entry.name}{entry.description}", indent=2)
60256031

60266032
if source_filter == "builtin":
@@ -6062,7 +6068,7 @@ def _resolve_argparse_subparser(name: str) -> argparse.ArgumentParser | None:
60626068
return None
60636069

60646070

6065-
SLASH_LOCALS_WITHOUT_ARGPARSE = {"help", "model", "reload", "quit"}
6071+
SLASH_LOCALS_WITHOUT_ARGPARSE = set(SLASH_LOCAL_NAMES)
60666072

60676073

60686074
def cmd_slash_inspect(args: argparse.Namespace) -> int:
@@ -6083,11 +6089,7 @@ def cmd_slash_inspect(args: argparse.Namespace) -> int:
60836089
if name.startswith("/"):
60846090
name = name[1:]
60856091

6086-
builtin_match: BuiltinSlashCommand | None = None
6087-
for entry in BUILTIN_SLASH_COMMANDS:
6088-
if entry.name == name:
6089-
builtin_match = entry
6090-
break
6092+
builtin_match: BuiltinSlashCommand | None = builtin_slash_by_name(name)
60916093

60926094
contributed_match: SlashCommandInfo | None = None
60936095
if builtin_match is None:

mythic_vibe_cli/protocols/mcp_tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ def build_tool_catalogue() -> list[McpTool]:
6262
surface.
6363
"""
6464
from ..commands import COMMAND_HANDLERS
65-
from ..runtime.slash_commands import BUILTIN_SLASH_COMMANDS
65+
from ..runtime.command_catalog import iter_builtin_slash_commands
6666

6767
descriptions = {
68-
entry.name: entry.description for entry in BUILTIN_SLASH_COMMANDS
68+
entry.name: entry.description for entry in iter_builtin_slash_commands()
6969
}
7070

7171
seen: set[str] = set()

mythic_vibe_cli/repl.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from .config import ConfigStore
3434
from .exit_codes import SUCCESS, USER_INPUT_ERROR
3535
from .patch import PatchManager
36-
from .runtime.slash_commands import BUILTIN_SLASH_COMMANDS
36+
from .runtime.command_catalog import iter_builtin_slash_commands
3737

3838

3939
PROMPT = "mythic-vibe> "
@@ -569,7 +569,7 @@ def _answer_with_selected_model(prompt: str, stdout: IO[str], context: ShellCont
569569
def _print_help(stdout: IO[str], project_root: Path) -> None:
570570
"""Print the slash-command catalog (builtin + plugin-contributed)."""
571571
print("Builtin slash commands:", file=stdout)
572-
for entry in BUILTIN_SLASH_COMMANDS:
572+
for entry in iter_builtin_slash_commands():
573573
print(f" /{entry.name}\t{entry.description}", file=stdout)
574574

575575
# Late-import the dispatcher so the REPL module stays cheap to import.

mythic_vibe_cli/runtime/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
- ``exec`` — subprocess execution primitive with timeout and cancel-event.
1212
"""
1313

14+
from .command_catalog import (
15+
ARGPARSE_ONLY_NAMES,
16+
CommandCatalogEntry,
17+
CommandCatalogValidation,
18+
SLASH_LOCAL_NAMES,
19+
build_command_catalog,
20+
builtin_slash_by_name,
21+
iter_builtin_slash_commands,
22+
validate_command_catalog,
23+
)
1424
from .event_bus import EventBus, EventBusController, create_event_bus
1525
from .event_log import (
1626
DEFAULT_EVENT_LOG_FILENAME,
@@ -79,4 +89,12 @@
7989
"synthetic_source_info",
8090
"exec_command",
8191
"ExecResult",
92+
"ARGPARSE_ONLY_NAMES",
93+
"CommandCatalogEntry",
94+
"CommandCatalogValidation",
95+
"SLASH_LOCAL_NAMES",
96+
"build_command_catalog",
97+
"builtin_slash_by_name",
98+
"iter_builtin_slash_commands",
99+
"validate_command_catalog",
82100
]
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Validated command catalog facade.
2+
3+
This module centralizes the rules that relate the public argparse
4+
command set to slash-visible builtins and interactive-local slash
5+
commands. It intentionally wraps the legacy ``BUILTIN_SLASH_COMMANDS``
6+
constant instead of replacing it in one large rewrite.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from typing import Iterable, Sequence
13+
14+
from .slash_commands import BUILTIN_SLASH_COMMANDS, BuiltinSlashCommand
15+
16+
17+
SLASH_LOCAL_NAMES = frozenset({"help", "model", "reload", "quit"})
18+
ARGPARSE_ONLY_NAMES = frozenset({"shell", "tui", "slash"})
19+
20+
21+
@dataclass(frozen=True)
22+
class CommandCatalogEntry:
23+
name: str
24+
description: str
25+
slash_visible: bool
26+
argparse_registered: bool
27+
interactive_local: bool = False
28+
source: str = "builtin"
29+
30+
def to_dict(self) -> dict[str, object]:
31+
return {
32+
"name": self.name,
33+
"description": self.description,
34+
"slash_visible": self.slash_visible,
35+
"argparse_registered": self.argparse_registered,
36+
"interactive_local": self.interactive_local,
37+
"source": self.source,
38+
}
39+
40+
41+
@dataclass(frozen=True)
42+
class CommandCatalogValidation:
43+
errors: tuple[str, ...] = ()
44+
45+
@property
46+
def ok(self) -> bool:
47+
return not self.errors
48+
49+
50+
def iter_builtin_slash_commands() -> tuple[BuiltinSlashCommand, ...]:
51+
"""Return the slash-visible builtin command catalog."""
52+
return BUILTIN_SLASH_COMMANDS
53+
54+
55+
def builtin_slash_by_name(name: str) -> BuiltinSlashCommand | None:
56+
for entry in iter_builtin_slash_commands():
57+
if entry.name == name:
58+
return entry
59+
return None
60+
61+
62+
def build_command_catalog(handler_names: Iterable[str]) -> tuple[CommandCatalogEntry, ...]:
63+
"""Build the top-level command catalog from handler names and slash data."""
64+
handlers = set(handler_names)
65+
entries: list[CommandCatalogEntry] = []
66+
for item in iter_builtin_slash_commands():
67+
entries.append(
68+
CommandCatalogEntry(
69+
name=item.name,
70+
description=item.description,
71+
slash_visible=True,
72+
argparse_registered=item.name in handlers,
73+
interactive_local=item.name in SLASH_LOCAL_NAMES,
74+
)
75+
)
76+
descriptions = {
77+
"shell": "Open the interactive companion shell",
78+
"tui": "Open the Textual Terminal User Interface",
79+
"slash": "Inspect slash command catalog entries",
80+
}
81+
for name in sorted(ARGPARSE_ONLY_NAMES & handlers):
82+
entries.append(
83+
CommandCatalogEntry(
84+
name=name,
85+
description=descriptions.get(name, f"Mythic Vibe CLI subcommand: {name}"),
86+
slash_visible=False,
87+
argparse_registered=True,
88+
)
89+
)
90+
return tuple(entries)
91+
92+
93+
def validate_command_catalog(
94+
handler_names: Iterable[str],
95+
*,
96+
builtin_commands: Sequence[BuiltinSlashCommand] | None = None,
97+
) -> CommandCatalogValidation:
98+
"""Validate command/slash catalog invariants.
99+
100+
Rules:
101+
- slash builtin names must be unique,
102+
- every non-local slash builtin must have an argparse handler,
103+
- every handler except argparse-only commands must have a slash entry,
104+
- interactive-local slash names must not also be argparse handlers.
105+
"""
106+
handlers = set(handler_names)
107+
builtins = tuple(BUILTIN_SLASH_COMMANDS if builtin_commands is None else builtin_commands)
108+
names = [entry.name for entry in builtins]
109+
builtin_names = set(names)
110+
errors: list[str] = []
111+
112+
duplicates = sorted({name for name in names if names.count(name) > 1})
113+
if duplicates:
114+
errors.append(f"duplicate slash builtin names: {duplicates}")
115+
116+
missing_handlers = sorted((builtin_names - SLASH_LOCAL_NAMES) - handlers)
117+
if missing_handlers:
118+
errors.append(f"slash builtins without argparse handlers: {missing_handlers}")
119+
120+
missing_slash = sorted((handlers - ARGPARSE_ONLY_NAMES) - builtin_names)
121+
if missing_slash:
122+
errors.append(f"argparse handlers without slash builtins: {missing_slash}")
123+
124+
locals_with_argparse = sorted(SLASH_LOCAL_NAMES & handlers)
125+
if locals_with_argparse:
126+
errors.append(f"interactive-local slash names registered in argparse: {locals_with_argparse}")
127+
128+
return CommandCatalogValidation(errors=tuple(errors))
129+
130+
131+
__all__ = [
132+
"ARGPARSE_ONLY_NAMES",
133+
"CommandCatalogEntry",
134+
"CommandCatalogValidation",
135+
"SLASH_LOCAL_NAMES",
136+
"build_command_catalog",
137+
"builtin_slash_by_name",
138+
"iter_builtin_slash_commands",
139+
"validate_command_catalog",
140+
]

mythic_vibe_cli/tui/picker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from textual.widgets.option_list import Option
2626

2727
from ..plugins.dispatcher import PluginHookDispatcher
28+
from ..runtime.command_catalog import iter_builtin_slash_commands
2829
from ..runtime.slash_commands import (
29-
BUILTIN_SLASH_COMMANDS,
3030
BuiltinSlashCommand,
3131
SlashCommandInfo,
3232
)
@@ -114,7 +114,7 @@ def gather_picker_entries(root: Path) -> list[PickerEntry]:
114114
``slash_commands()`` raises are skipped silently per dispatcher contract.
115115
"""
116116
entries: list[PickerEntry] = [
117-
PickerEntry.from_builtin(item) for item in BUILTIN_SLASH_COMMANDS
117+
PickerEntry.from_builtin(item) for item in iter_builtin_slash_commands()
118118
]
119119
try:
120120
with PluginHookDispatcher(root) as dispatcher:

tests/test_command_catalog.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Phase 3 command catalog contract tests."""
2+
3+
from __future__ import annotations
4+
5+
import unittest
6+
7+
from mythic_vibe_cli.commands import COMMAND_HANDLERS
8+
from mythic_vibe_cli.runtime.command_catalog import (
9+
ARGPARSE_ONLY_NAMES,
10+
SLASH_LOCAL_NAMES,
11+
build_command_catalog,
12+
iter_builtin_slash_commands,
13+
validate_command_catalog,
14+
)
15+
from mythic_vibe_cli.runtime.slash_commands import BuiltinSlashCommand
16+
17+
18+
class CommandCatalogTests(unittest.TestCase):
19+
def test_runtime_catalog_validates_current_handlers(self) -> None:
20+
result = validate_command_catalog(COMMAND_HANDLERS)
21+
self.assertTrue(result.ok, msg="\n".join(result.errors))
22+
23+
def test_catalog_entries_classify_slash_and_argparse_surfaces(self) -> None:
24+
entries = {entry.name: entry for entry in build_command_catalog(COMMAND_HANDLERS)}
25+
26+
for name in SLASH_LOCAL_NAMES:
27+
self.assertTrue(entries[name].slash_visible)
28+
self.assertTrue(entries[name].interactive_local)
29+
self.assertFalse(entries[name].argparse_registered)
30+
31+
for name in ARGPARSE_ONLY_NAMES:
32+
self.assertTrue(entries[name].argparse_registered)
33+
self.assertFalse(entries[name].slash_visible)
34+
35+
self.assertTrue(entries["status"].slash_visible)
36+
self.assertTrue(entries["status"].argparse_registered)
37+
self.assertFalse(entries["status"].interactive_local)
38+
39+
def test_validation_reports_duplicate_builtin_names(self) -> None:
40+
duplicate = (
41+
*iter_builtin_slash_commands(),
42+
BuiltinSlashCommand("status", "duplicate status"),
43+
)
44+
result = validate_command_catalog(COMMAND_HANDLERS, builtin_commands=duplicate)
45+
self.assertFalse(result.ok)
46+
self.assertTrue(any("duplicate slash builtin names" in error for error in result.errors))
47+
48+
def test_validation_reports_handler_without_slash_entry(self) -> None:
49+
result = validate_command_catalog({"status", "ghost-command"})
50+
self.assertFalse(result.ok)
51+
self.assertTrue(any("argparse handlers without slash builtins" in error for error in result.errors))

0 commit comments

Comments
 (0)