feat(system): CLI stderr standardization + drone error propagation (FPLAN-0032)

CLI display.py now routes error() and warning() to stderr via err_console.
Added fatal() for unrecoverable errors. Drone imports err_console from CLI
instead of creating its own. RegistryMismatchError added to drone exception
hierarchy for credential verification failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-13 23:27:51 -07:00
co-authored by Claude Opus 4.6
parent c6e504f861
commit 154e92d13e
7 changed files with 62 additions and 27 deletions
+4 -1
View File
@@ -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
+26 -4
View File
@@ -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',
]
@@ -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.
+15 -20
View File
@@ -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("@"):
@@ -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
@@ -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
@@ -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",