From b3067ff45ad28262ae0a558b7f6468b2941ed37f Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 2 Mar 2026 23:06:28 -0800 Subject: [PATCH] =?UTF-8?q?feat(drone):=20add=20introspective=20pattern=20?= =?UTF-8?q?=E2=80=94=20no-args=20discovery=20vs=20--help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the internal AIPass navigation pattern: - `drone @module` (no args) → introspective: shows what's connected - `drone @module --help` → help: shows usage documentation Seedgo introspective lists all discovered plugins with descriptions. Modules that don't implement get_introspective() fall back to help. 442 tests pass. 5 new introspective tests. Co-Authored-By: Claude Opus 4.6 --- src/aipass/drone/cli.py | 20 +++++++++-- src/aipass/drone/modules.py | 26 ++++++++++++++ src/seedgo/drone_adapter.py | 72 +++++++++++++++++++++++++++++++++++++ tests/test_drone_modules.py | 46 ++++++++++++++++++++++-- 4 files changed, 158 insertions(+), 6 deletions(-) diff --git a/src/aipass/drone/cli.py b/src/aipass/drone/cli.py index a2725121..7ffd38a8 100644 --- a/src/aipass/drone/cli.py +++ b/src/aipass/drone/cli.py @@ -31,6 +31,7 @@ from aipass.drone.modules import ( is_module, list_modules, get_module_info, + get_module_introspective, route_module_command, get_module_help, ) @@ -171,9 +172,22 @@ def _cmd_branch(args: list[str]) -> None: def _cmd_module(name: str, args: list[str]) -> None: - """Handle routing to an internal module (e.g. @seedgo).""" - # drone @module --help (or no args) - if not args or args == ["--help"]: + """Handle routing to an internal module (e.g. @seedgo). + + No args → introspective (discovery: what's connected). + --help → help text (usage documentation). + """ + # drone @module (no args) → introspective / discovery + if not args: + intro_text = get_module_introspective(name) + if intro_text: + print(intro_text, end="") + else: + print(f"No information available for @{name}.") + sys.exit(0) + + # drone @module --help → usage documentation + if args == ["--help"]: help_text = get_module_help(name) if help_text: print(help_text, end="") diff --git a/src/aipass/drone/modules.py b/src/aipass/drone/modules.py index 8c403244..27751b39 100644 --- a/src/aipass/drone/modules.py +++ b/src/aipass/drone/modules.py @@ -95,6 +95,32 @@ def get_module_help(name: str, command: str | None = None) -> str: return "" +def get_module_introspective(name: str) -> str: + """Get introspective view from a module's drone adapter. + + Introspective = discovery mode (no args): shows what's connected. + Falls back to help text if the adapter doesn't implement get_introspective(). + + Returns introspective string, or empty string if unavailable. + """ + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return "" + try: + mod = importlib.import_module(adapter_path) + # Try introspective first, fall back to help + intro_fn = getattr(mod, "get_introspective", None) + if intro_fn is not None: + return intro_fn() + # Fallback: use help if no introspective defined + help_fn = getattr(mod, "get_help", None) + if help_fn is not None: + return help_fn(None) + return "" + except (ImportError, AttributeError): + return "" + + def register_module(name: str, adapter_path: str) -> None: """Register a new module. Used for dynamic registration (e.g., plugins).""" _MODULE_REGISTRY[name] = adapter_path diff --git a/src/seedgo/drone_adapter.py b/src/seedgo/drone_adapter.py index e6364671..62ab82ad 100644 --- a/src/seedgo/drone_adapter.py +++ b/src/seedgo/drone_adapter.py @@ -2,6 +2,12 @@ Provides the drone module interface so `drone @seedgo` commands work. Delegates to the seedgo CLI via subprocess for clean stdout/stderr capture. + +Drone adapter contract: + - DRONE_MODULE: dict with name, version, description + - handle_command(command, args) -> dict with stdout/stderr/exit_code + - get_help(command=None) -> str (usage documentation) + - get_introspective() -> str (discovery — what's connected) """ from __future__ import annotations @@ -37,6 +43,72 @@ Examples: """ +def get_introspective() -> str: + """Return discovery view — what's connected to this module. + + Called when user types `drone @seedgo` with no arguments. + Shows connected plugins and available commands at a glance. + """ + # Discover plugins by calling seedgo list and parsing output + try: + result = subprocess.run( + [sys.executable, "-m", "seedgo", "list"], + capture_output=True, + text=True, + timeout=10, + ) + plugin_output = result.stdout if result.stdout else "" + except (subprocess.TimeoutExpired, FileNotFoundError): + plugin_output = "" + + # Count plugins from the output + # seedgo list outputs lines like " plugin-name source file_types description" + plugin_lines = [] + in_table = False + for line in plugin_output.splitlines(): + stripped = line.strip() + if stripped.startswith("---"): + in_table = True + continue + if in_table and stripped: + parts = stripped.split() + if len(parts) >= 2: + name = parts[0] + # Find description — everything after the file types column + # The format is: NAME SOURCE FILE_TYPES DESCRIPTION + desc = "" + if len(parts) >= 4: + # Rejoin from 3rd column onwards as description + # Actually the columns are fixed-width, let's just grab the name + desc = " ".join(parts[3:]) if len(parts) > 3 else "" + plugin_lines.append((name, desc)) + + lines = [] + lines.append(f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})") + lines.append("") + lines.append("Auto-discovered plugin orchestration") + lines.append("") + + if plugin_lines: + lines.append(f"Discovered Plugins: {len(plugin_lines)}") + lines.append("") + # Find max name length for alignment + max_name = max(len(name) for name, _ in plugin_lines) if plugin_lines else 20 + for name, desc in plugin_lines: + if desc: + lines.append(f" * {name:<{max_name}} {desc}") + else: + lines.append(f" * {name}") + else: + lines.append("Discovered Plugins: 0") + + lines.append("") + lines.append("Run 'drone @seedgo --help' for usage information") + lines.append("") + + return "\n".join(lines) + + def handle_command(command: str, args: list[str] | None = None) -> dict: """Handle a command routed by drone. diff --git a/tests/test_drone_modules.py b/tests/test_drone_modules.py index 782be9a3..68997ee9 100644 --- a/tests/test_drone_modules.py +++ b/tests/test_drone_modules.py @@ -18,6 +18,7 @@ from aipass.drone.modules import ( ModuleInfo, get_module_help, get_module_info, + get_module_introspective, is_module, list_modules, register_module, @@ -179,6 +180,42 @@ class TestModuleHelp: assert get_module_help("nonexistent") == "" +class TestModuleIntrospective: + """Test module introspective (discovery) retrieval.""" + + def test_seedgo_introspective_shows_plugins(self): + """get_module_introspective for seedgo lists discovered plugins.""" + text = get_module_introspective("seedgo") + assert text != "" + assert "SEEDGO" in text + assert "Discovered Plugins" in text + # Should show at least some known plugins + assert "drone-compliance" in text or "no-bare-except" in text + + def test_introspective_shows_version(self): + """Introspective includes the module version.""" + text = get_module_introspective("seedgo") + assert "1.0.0" in text + + def test_introspective_points_to_help(self): + """Introspective tells you how to get help.""" + text = get_module_introspective("seedgo") + assert "--help" in text + + def test_unknown_module_introspective_returns_empty(self): + """get_module_introspective for unknown module returns empty string.""" + assert get_module_introspective("nonexistent") == "" + + def test_introspective_differs_from_help(self): + """Introspective and help return different content.""" + intro = get_module_introspective("seedgo") + help_text = get_module_help("seedgo") + # Both non-empty but different + assert intro != "" + assert help_text != "" + assert intro != help_text + + # --------------------------------------------------------------------------- # CLI integration — drone systems shows modules # --------------------------------------------------------------------------- @@ -227,11 +264,14 @@ class TestCLIModuleRouting: assert "seedgo" in out assert "check" in out - def test_seedgo_no_args_shows_help(self): - """drone @seedgo with no command shows help.""" + def test_seedgo_no_args_shows_introspective(self): + """drone @seedgo with no command shows introspective (discovery).""" code, out, _ = _run_cli("@seedgo") assert code == 0 - assert "seedgo" in out + assert "SEEDGO" in out + assert "Discovered Plugins" in out + # Should NOT be the help text (which has "Commands:" section) + assert "Examples:" not in out def test_seedgo_list(self): """drone @seedgo list shows plugins."""