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:
co-authored by
Claude Opus 4.6
parent
c6e504f861
commit
154e92d13e
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user