@@ -291,6 +291,18 @@ def _build_validate_parser(subparsers: argparse._SubParsersAction) -> None:
291291 default = None ,
292292 help = "Path to argus.yml config file (default: auto-detect)" ,
293293 )
294+ validate_parser .add_argument (
295+ "--check-tools" ,
296+ action = "store_true" ,
297+ default = False ,
298+ help = "Also check scanner tool availability (local + Docker)" ,
299+ )
300+ validate_parser .add_argument (
301+ "--strict" ,
302+ action = "store_true" ,
303+ default = False ,
304+ help = "Treat warnings as errors (exit non-zero). Useful in CI." ,
305+ )
294306
295307
296308def _build_report_parser (subparsers : argparse ._SubParsersAction ) -> None :
@@ -865,30 +877,118 @@ def cmd_validate(args: argparse.Namespace) -> int:
865877 warnings = [e for e in errors if e .level == "warning" ]
866878 fatal = [e for e in errors if e .level == "error" ]
867879
880+ strict = getattr (args , "strict" , False )
881+
868882 if not errors :
869883 print (f"✅ { config_path } is valid" )
870- # Show summary
871- scanners = data .get ("scanners" , {})
872- enabled = sum (1 for s in scanners .values () if isinstance (s , dict ) and s .get ("enabled" , True ))
873- print (f" Scanners: { len (scanners )} configured, { enabled } enabled" )
874- fmt = data .get ("reporting" , {}).get ("formats" , ["terminal" ])
875- print (f" Formats: { ', ' .join (fmt ) if isinstance (fmt , list ) else fmt } " )
876- print (f" Backend: { data .get ('execution' , {}).get ('backend' , 'auto' )} " )
877- return EXIT_SUCCESS
884+ else :
885+ for w in warnings :
886+ print (f"⚠️ { w } " )
887+ for e in fatal :
888+ print (f"❌ { e } " )
878889
879- for w in warnings :
880- print (f"⚠️ { w } " )
881- for e in fatal :
882- print (f"❌ { e } " )
890+ if fatal :
891+ print (f"\n { len (fatal )} error(s), { len (warnings )} warning(s). Fix and retry." )
892+ return EXIT_ERROR
883893
884- if fatal :
885- print (f"\n { len (fatal )} error(s), { len (warnings )} warning(s). Fix and retry." )
886- return EXIT_ERROR
894+ if strict and warnings :
895+ print (f"\n ❌ { len (warnings )} warning(s) treated as errors (--strict)" )
896+ return EXIT_ERROR
897+
898+ print (f"\n ✅ { config_path } is valid ({ len (warnings )} warning(s))" )
899+
900+ # Show summary with enabled/disabled breakdown
901+ scanners = data .get ("scanners" , {})
902+ enabled_names = []
903+ disabled_names = []
904+ for name , cfg in scanners .items ():
905+ if isinstance (cfg , dict ) and not cfg .get ("enabled" , True ):
906+ disabled_names .append (name )
907+ else :
908+ enabled_names .append (name )
909+ print (f" Scanners: { len (enabled_names )} enabled, { len (disabled_names )} disabled" )
910+ if enabled_names :
911+ print (f" enabled: { ', ' .join (enabled_names )} " )
912+ if disabled_names :
913+ print (f" disabled: { ', ' .join (disabled_names )} " )
914+ fmt = data .get ("reporting" , {}).get ("formats" , ["terminal" ])
915+ print (f" Formats: { ', ' .join (fmt ) if isinstance (fmt , list ) else fmt } " )
916+ backend = data .get ("execution" , {}).get ("backend" , "auto" )
917+ print (f" Backend: { backend } " )
918+
919+ # Tool readiness check
920+ if getattr (args , "check_tools" , False ) and enabled_names :
921+ registry = data .get ("execution" , {}).get ("registry" , "" )
922+ unavailable = _check_tool_readiness (enabled_names , backend , registry )
923+ if unavailable and strict :
924+ print (f"\n ❌ { len (unavailable )} scanner(s) unavailable (--strict)" )
925+ return EXIT_ERROR
887926
888- print (f"\n ✅ { config_path } is valid ({ len (warnings )} warning(s))" )
889927 return EXIT_SUCCESS
890928
891929
930+ def _check_tool_readiness (
931+ enabled_names : list [str ], backend : str , registry : str = ""
932+ ) -> list [str ]:
933+ """Check whether enabled scanners can actually run.
934+
935+ Returns list of scanner names that cannot run.
936+ """
937+ import shutil
938+ from argus .scanners import SCANNER_REGISTRY
939+ from argus .containers import get_image
940+
941+ docker_available = shutil .which ("docker" ) is not None
942+ unavailable = []
943+
944+ def _resolve_image (image : str ) -> str :
945+ """Apply registry override to an image reference."""
946+ if not registry or not image :
947+ return image
948+ parts = image .split ("/" , 1 )
949+ if len (parts ) == 2 and ("." in parts [0 ] or ":" in parts [0 ]):
950+ return f"{ registry } /{ parts [1 ]} "
951+ return f"{ registry } /{ image } "
952+
953+ if registry :
954+ print (f"\n Registry: { registry } " )
955+ print ("\n Tool readiness:" )
956+ for name in enabled_names :
957+ cls = SCANNER_REGISTRY .get (name )
958+ if not cls :
959+ print (f" { name } : ⚠️ unknown scanner (not in registry)" )
960+ unavailable .append (name )
961+ continue
962+
963+ scanner = cls ()
964+ local = scanner .is_available ()
965+ raw_image = get_image (name ) or getattr (scanner , "container_image" , "" )
966+ image = _resolve_image (raw_image )
967+
968+ if local :
969+ print (f" { name } : ✅ installed locally" )
970+ elif backend == "local" :
971+ install_cmd = scanner .install_command () or "see docs"
972+ print (f" { name } : ❌ not found (install: { install_cmd } )" )
973+ unavailable .append (name )
974+ elif not docker_available :
975+ install_cmd = scanner .install_command () or "see docs"
976+ print (f" { name } : ❌ not found, Docker not available (install: { install_cmd } )" )
977+ unavailable .append (name )
978+ elif image :
979+ print (f" { name } : 🐳 will use container ({ image } )" )
980+ else :
981+ install_cmd = scanner .install_command () or "see docs"
982+ print (f" { name } : ❌ not found, no container image (install: { install_cmd } )" )
983+ unavailable .append (name )
984+
985+ if not docker_available and backend != "local" :
986+ print ("\n ⚠️ Docker not found — scanners without local installs will fail" )
987+ print (" Install Docker or set execution.backend: local in argus.yml" )
988+
989+ return unavailable
990+
991+
892992def cmd_collect (args : argparse .Namespace ) -> int :
893993 """Execute the collect subcommand — merge parallel CI results."""
894994 from argus .collect import collect_results
@@ -973,10 +1073,47 @@ def _show_scanner_help(scanner_name: str) -> None:
9731073 sys .exit (EXIT_SUCCESS )
9741074
9751075
1076+ def _show_logo_easter_egg () -> int :
1077+ """Render the Argus logo banner with a scroll effect.
1078+
1079+ Hidden trigger: `argus __logo`
1080+ Not part of argparse, so it stays out of generated docs/help text.
1081+ """
1082+ import time
1083+
1084+ try :
1085+ from argus .init import _load_banner
1086+ lines = _load_banner ().splitlines ()
1087+ except Exception :
1088+ lines = [
1089+ "\033 [1;32mA R G U S\033 [0m" ,
1090+ "\033 [90mPerception is Protection\033 [0m" ,
1091+ ]
1092+
1093+ for line in lines :
1094+ print (line , file = sys .stderr )
1095+ time .sleep (0.06 if line .strip () else 0.15 )
1096+ print (file = sys .stderr )
1097+
1098+ return EXIT_SUCCESS
1099+
1100+
9761101def main (argv : list [str ] | None = None ) -> None :
9771102 """CLI entry point. Parse arguments and dispatch to the appropriate subcommand."""
9781103 # Intercept `argus scan <name> --help` before argparse exits
979- raw_args = argv if argv is not None else sys .argv [1 :]
1104+ raw_args = list (argv ) if argv is not None else list (sys .argv [1 :])
1105+ if len (raw_args ) >= 1 and raw_args [0 ] == "__logo" :
1106+ sys .exit (_show_logo_easter_egg ())
1107+
1108+ # Hidden inline trigger: allow `argus <command> ... __logo`.
1109+ # Keep this out of argparse so it remains undocumented.
1110+ if "__logo" in raw_args [1 :]:
1111+ _show_logo_easter_egg ()
1112+ raw_args = [
1113+ token for i , token in enumerate (raw_args )
1114+ if not (i > 0 and token == "__logo" )
1115+ ]
1116+
9801117 if (len (raw_args ) >= 3
9811118 and raw_args [0 ] == "scan"
9821119 and raw_args [- 1 ] in ("--help" , "-h" )
@@ -985,7 +1122,7 @@ def main(argv: list[str] | None = None) -> None:
9851122 _show_scanner_help (scanner_name )
9861123
9871124 parser = build_parser ()
988- args = parser .parse_args (argv )
1125+ args = parser .parse_args (raw_args )
9891126
9901127 if args .command is None :
9911128 parser .print_help ()
0 commit comments