diff --git a/src/aipass/cli/apps/modules/__init__.py b/src/aipass/cli/apps/modules/__init__.py index f9ffbaf9..68b3e9df 100644 --- a/src/aipass/cli/apps/modules/__init__.py +++ b/src/aipass/cli/apps/modules/__init__.py @@ -21,7 +21,7 @@ PATTERN (from Prax): """ # Rich console (primary service - like Prax logger) -from aipass.cli.apps.modules.display import console +from aipass.cli.apps.modules.display import console, err_console # Display functions from aipass.cli.apps.modules.display import ( @@ -29,6 +29,7 @@ from aipass.cli.apps.modules.display import ( success, error, warning, + fatal, section ) @@ -41,12 +42,14 @@ from aipass.cli.apps.modules.templates import ( __all__ = [ # Rich console (primary service) 'console', + 'err_console', # Display 'header', 'success', 'error', 'warning', + 'fatal', 'section', # Templates diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index 2a823714..6be98483 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -35,6 +35,7 @@ from rich.columns import Columns # Initialize Rich console (lowercase follows service instance pattern) CONSOLE = Console() # Internal constant console = CONSOLE # Primary export (lowercase service instance pattern) +err_console = Console(stderr=True) # Stderr console for error/warning output # Trigger loaded lazily to avoid circular import _trigger = None @@ -325,9 +326,9 @@ def error(message: str, suggestion: str | None = None) -> None: Example: error('Branch not found', suggestion='Check branch name spelling') """ - CONSOLE.print(f"❌ [red bold]{message}[/red bold]") + err_console.print(f"❌ [red bold]{message}[/red bold]") if suggestion: - CONSOLE.print(f" [yellow]→ Try: {suggestion}[/yellow]") + err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]") def warning(message: str, details: str | None = None) -> None: @@ -341,9 +342,28 @@ def warning(message: str, details: str | None = None) -> None: Example: warning('Branch already exists, skipping') """ - CONSOLE.print(f"⚠️ [yellow]{message}[/yellow]") + err_console.print(f"⚠️ [yellow]{message}[/yellow]") if details: - CONSOLE.print(f" [dim]{details}[/dim]") + err_console.print(f" [dim]{details}[/dim]") + + +def fatal(message: str, suggestion: str | None = None) -> None: + """ + Display error message with Rich styling and exit with code 1 + + Like error() but terminates the process. Use for unrecoverable failures. + + Args: + message: Error message + suggestion: Optional suggestion for fixing + + Example: + fatal('Config file missing', suggestion='Run aipass init first') + """ + err_console.print(f"❌ [red bold]{message}[/red bold]") + if suggestion: + err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]") + sys.exit(1) def section(title: str) -> None: @@ -369,10 +389,12 @@ def section(title: str) -> None: __all__ = [ 'console', # Primary export (service instance pattern) 'CONSOLE', # Internal constant (kept for backward compatibility) + 'err_console', # Stderr console for error/warning output 'header', 'success', 'error', 'warning', + 'fatal', 'section', ] diff --git a/src/aipass/devpulse/dplan-003/credential_model.md b/src/aipass/devpulse/dplan-003/credential_model.md index 27c5e43a..8bfb76ed 100644 --- a/src/aipass/devpulse/dplan-003/credential_model.md +++ b/src/aipass/devpulse/dplan-003/credential_model.md @@ -149,3 +149,6 @@ Discovered during testing. Reference for future work. - **2026-03-14:** Scope limited to `src/aipass/` branches only. Commons and skills excluded. - **2026-03-14:** All 4 open questions resolved. `init_project.py` built and tested — creates registry with UUID, passport with matching registry_id, .trinity/, .aipass/, AIPASS.md. Tested with temp directory — drone isolation confirmed (only built-in modules visible, not AIPass branches). - **2026-03-14:** UUID migration executed (FPLAN-0030). Registry + 13 passports updated. Registry and passports are gitignored — UUID is machine-local, not repo state. +- **2026-03-14:** Drone verification added (`_verify_registry_credential` in load_registry). Drone dispatched and fixed error propagation — new `RegistryMismatchError` class separates "not found" (fallback OK) from "mismatch" (hard error). Spawn templates updated with `{{REGISTRY_ID}}` placeholder. +- **2026-03-14:** Stage 1 complete. Credential model is end-to-end: init creates credentialed registries, drone verifies on load, spawn injects into new passports, mismatch = hard error. Remaining: drone CLI stderr surfacing (DPLAN-0031), `aipass init` CLI command (future). +- **2026-03-14:** FPLAN-0032 executed — CLI stderr standardization Phase 1+2 done. CLI owns err_console, error()/warning() go to stderr. Drone imports from CLI instead of creating its own Console(stderr=True). Credential mismatch errors now properly surface to terminal via stderr. The stderr issue that blocked error visibility during DPLAN-003 testing is resolved. diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 3d0d04d9..58fc6f1b 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -16,13 +16,9 @@ Standard branch entry point (apps/drone.py pattern). import sys from typing import List -from rich.console import Console - from aipass.prax import logger -from aipass.cli.apps.modules import console - -err_console = Console(stderr=True) -from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError +from aipass.cli.apps.modules import console, err_console +from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError, RegistryError from aipass.drone.apps.modules.discovery import get_help from aipass.drone.apps.modules.resolver import list_branches from aipass.drone.apps.modules.router import route_command @@ -187,10 +183,7 @@ def _handle_target(args: List[str]) -> int: if not rest: try: result = route_command(target) - except BranchNotFoundError as exc: - err_console.print(f"drone: {exc}") - return 1 - except CommandExecutionError as exc: + except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: err_console.print(f"drone: {exc}") return 1 if result.stdout: @@ -207,10 +200,7 @@ def _handle_target(args: List[str]) -> int: console.print(result.text, end="", highlight=False) else: console.print(f"No help available for {target}.") - except BranchNotFoundError as exc: - err_console.print(f"drone: {exc}") - return 1 - except CommandExecutionError as exc: + except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: err_console.print(f"drone: {exc}") return 1 return 0 @@ -228,10 +218,7 @@ def _handle_target(args: List[str]) -> int: args=cmd_args if cmd_args else None, interactive=interactive, ) - except BranchNotFoundError as exc: - err_console.print(f"drone: {exc}") - return 1 - except CommandExecutionError as exc: + except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: err_console.print(f"drone: {exc}") return 1 @@ -252,7 +239,11 @@ def main() -> int: # No args -> introspection if not args: - show_introspection() + try: + show_introspection() + except RegistryError as exc: + err_console.print(f"drone: {exc}") + return 1 return 0 # --version @@ -269,7 +260,11 @@ def main() -> int: # systems — list branches and modules if command == "systems": - return _handle_systems() + try: + return _handle_systems() + except RegistryError as exc: + err_console.print(f"drone: {exc}") + return 1 # @target — route to branch or module if command.startswith("@"): diff --git a/src/aipass/drone/apps/handlers/exceptions.py b/src/aipass/drone/apps/handlers/exceptions.py index 745ba313..4fb93f7d 100644 --- a/src/aipass/drone/apps/handlers/exceptions.py +++ b/src/aipass/drone/apps/handlers/exceptions.py @@ -43,6 +43,15 @@ class RegistryNotFoundError(RegistryError): pass +class RegistryMismatchError(RegistryError): + """Raised when registry credential doesn't match caller's passport. + + NOT recoverable — unlike RegistryNotFoundError (no file, use fallback), + a mismatch means the wrong registry was found. Must error, not fall back. + """ + pass + + class RegistryCorruptError(RegistryError): """Raised when the registry file is corrupted or invalid JSON.""" pass diff --git a/src/aipass/drone/apps/handlers/registry_handler.py b/src/aipass/drone/apps/handlers/registry_handler.py index 67cab989..73249ac1 100644 --- a/src/aipass/drone/apps/handlers/registry_handler.py +++ b/src/aipass/drone/apps/handlers/registry_handler.py @@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional from aipass.prax import logger from .exceptions import ( RegistryCorruptError, + RegistryMismatchError, RegistryNotFoundError, RegistryPermissionError, ) @@ -133,12 +134,12 @@ def _verify_registry_credential(registry_path: Path, registry_data: Dict[str, An return if passport_id != registry_id: - raise RegistryNotFoundError( + raise RegistryMismatchError( f"Registry mismatch: citizen belongs to registry " f"'{passport_id}' but found registry '{registry_id}' " f"at {registry_path}" ) - except RegistryNotFoundError: + except RegistryMismatchError: raise except Exception: pass # Verification should never crash drone diff --git a/src/aipass/drone/apps/modules/__init__.py b/src/aipass/drone/apps/modules/__init__.py index 51d28f48..cee4ee07 100644 --- a/src/aipass/drone/apps/modules/__init__.py +++ b/src/aipass/drone/apps/modules/__init__.py @@ -8,6 +8,7 @@ from aipass.drone.apps.handlers.exceptions import ( InvalidPathError, RegistryCorruptError, RegistryError, + RegistryMismatchError, RegistryNotFoundError, RegistryPermissionError, RoutingError, @@ -23,6 +24,7 @@ __all__ = [ "InvalidPathError", "RegistryCorruptError", "RegistryError", + "RegistryMismatchError", "RegistryNotFoundError", "RegistryPermissionError", "RoutingError",