88
99from .dedicated import _run_bcasl_headless , run_dedicated_cli
1010from .headless_ops import (
11+ bcasl_doctor_payload ,
12+ bcasl_list_payload ,
13+ ci_smoke_payload ,
1114 doctor_payload ,
1215 emit_json ,
16+ engine_config_path_payload ,
17+ engine_config_show_payload ,
1318 engine_doctor_payload ,
1419 engine_info_payload ,
1520 engine_list_payload ,
3338 click = None
3439
3540
41+ EXIT_OK = 0
42+ EXIT_RUNTIME_ERROR = 1
43+ EXIT_USAGE_ERROR = 2
44+ EXIT_PRECHECK_FAILED = 3
45+ EXIT_WORKSPACE_INVALID = 4
46+ EXIT_ENGINE_NOT_FOUND = 5
47+
48+
3649def has_click () -> bool :
3750 return click is not None
3851
3952
4053def _echo_payload (payload , as_json : bool = False ) -> None :
4154 if as_json :
42- plain (emit_json (payload ))
55+ text = emit_json (payload )
56+ sys .stdout .write (text )
57+ if not text .endswith ("\n " ):
58+ sys .stdout .write ("\n " )
59+ sys .stdout .flush ()
4360 return
4461 if isinstance (payload , str ):
4562 plain (payload )
4663 else :
4764 plain (str (payload ))
4865
4966
67+ def _emit_and_exit (payload , code : int , as_json : bool = False ) -> None :
68+ _echo_payload (payload , as_json = as_json )
69+ raise click .exceptions .Exit (code )
70+
71+
5072def _resolve_workspace_path (workspace : str | None ) -> str | None :
5173 if not workspace :
5274 return None
@@ -158,6 +180,7 @@ def cli(
158180 plain (" workspace Inspect workspace state" )
159181 plain (" doctor Global diagnostics" )
160182 plain (" scaffold Generate starter templates" )
183+ plain (" ci Run CI-friendly smoke checks" )
161184 ctx .exit (0 )
162185
163186 if ctx .invoked_subcommand is None :
@@ -220,6 +243,8 @@ def engine_list(workspace, as_json):
220243 def engine_info (engine_id , workspace , as_json ):
221244 payload = engine_info_payload (engine_id , workspace = _resolve_workspace_path (workspace ))
222245 if as_json :
246+ if not payload .get ("found" ):
247+ _emit_and_exit (payload , EXIT_ENGINE_NOT_FOUND , as_json = True )
223248 _echo_payload (payload , as_json = True )
224249 return
225250 if not payload .get ("found" ):
@@ -238,9 +263,14 @@ def engine_info(engine_id, workspace, as_json):
238263 @click .argument ("engine_id" )
239264 @click .option ("-w" , "--workspace" , type = click .Path (exists = False ))
240265 @click .option ("--json" , "as_json" , is_flag = True )
241- def engine_compat (engine_id , workspace , as_json ):
266+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when compatibility checks fail" )
267+ def engine_compat (engine_id , workspace , as_json , strict ):
242268 payload = engine_info_payload (engine_id , workspace = _resolve_workspace_path (workspace ))
243269 if as_json :
270+ if not payload .get ("found" ):
271+ _emit_and_exit (payload , EXIT_ENGINE_NOT_FOUND , as_json = True )
272+ if strict and not payload ["engine" ]["compatible" ]:
273+ _emit_and_exit (payload , EXIT_PRECHECK_FAILED , as_json = True )
244274 _echo_payload (payload , as_json = True )
245275 return
246276 if not payload .get ("found" ):
@@ -252,19 +282,26 @@ def engine_compat(engine_id, workspace, as_json):
252282 error (f"Engine not compatible: { engine_id } " )
253283 if eng .get ("message" ):
254284 warn (f"Reason: { eng ['message' ]} " )
285+ if strict :
286+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
255287
256288 @engine .command ("doctor" )
257289 @click .argument ("engine_id" )
258290 @click .argument ("file_path" , required = False , type = click .Path (exists = False ))
259291 @click .option ("-w" , "--workspace" , type = click .Path (exists = False ))
260292 @click .option ("--json" , "as_json" , is_flag = True )
261- def engine_doctor (engine_id , file_path , workspace , as_json ):
293+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when doctor checks fail" )
294+ def engine_doctor (engine_id , file_path , workspace , as_json , strict ):
262295 payload = engine_doctor_payload (
263296 engine_id ,
264297 workspace = _resolve_workspace_path (workspace ),
265298 file_path = _resolve_workspace_path (file_path ),
266299 )
267300 if as_json :
301+ if "checks" not in payload :
302+ _emit_and_exit (payload , EXIT_ENGINE_NOT_FOUND , as_json = True )
303+ if strict and any (not check ["ok" ] for check in payload ["checks" ]):
304+ _emit_and_exit (payload , EXIT_PRECHECK_FAILED , as_json = True )
268305 _echo_payload (payload , as_json = True )
269306 return
270307 if "checks" not in payload :
@@ -273,6 +310,54 @@ def engine_doctor(engine_id, file_path, workspace, as_json):
273310 for check in payload ["checks" ]:
274311 status = "OK" if check ["ok" ] else "FAIL"
275312 plain (f" [{ status } ] { check ['name' ]} : { check .get ('message' ) or '' } " )
313+ if strict and any (not check ["ok" ] for check in payload ["checks" ]):
314+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
315+
316+ @engine .group ("config" )
317+ def engine_config ():
318+ """Inspect persisted per-workspace engine configuration."""
319+
320+ @engine_config .command ("show" )
321+ @click .argument ("engine_id" )
322+ @click .option ("-w" , "--workspace" , required = True , type = click .Path (exists = False ))
323+ @click .option ("--json" , "as_json" , is_flag = True )
324+ def engine_config_show (engine_id , workspace , as_json ):
325+ payload = engine_config_show_payload (
326+ engine_id , workspace = _resolve_workspace_path (workspace )
327+ )
328+ if as_json :
329+ if not payload .get ("found" , True ):
330+ _emit_and_exit (payload , EXIT_ENGINE_NOT_FOUND , as_json = True )
331+ if payload .get ("error" ) == "workspace is required" :
332+ _emit_and_exit (payload , EXIT_WORKSPACE_INVALID , as_json = True )
333+ _echo_payload (payload , as_json = True )
334+ return
335+ if payload .get ("error" ) == "workspace is required" :
336+ raise click .ClickException ("Workspace is required" )
337+ if not payload .get ("found" , True ):
338+ raise click .ClickException (f"Engine not found: { engine_id } " )
339+ plain (f"Engine config: { engine_id } " )
340+ plain (f" Workspace: { payload .get ('workspace' )} " )
341+ plain (f" Path: { payload .get ('path' )} " )
342+ plain (f" Exists: { 'yes' if payload .get ('exists' ) else 'no' } " )
343+ plain (emit_json (payload .get ("config" , {})))
344+
345+ @engine_config .command ("path" )
346+ @click .argument ("engine_id" )
347+ @click .option ("-w" , "--workspace" , required = True , type = click .Path (exists = False ))
348+ @click .option ("--json" , "as_json" , is_flag = True )
349+ def engine_config_path (engine_id , workspace , as_json ):
350+ payload = engine_config_path_payload (
351+ engine_id , workspace = _resolve_workspace_path (workspace )
352+ )
353+ if as_json :
354+ if payload .get ("error" ) == "workspace is required" :
355+ _emit_and_exit (payload , EXIT_WORKSPACE_INVALID , as_json = True )
356+ _echo_payload (payload , as_json = True )
357+ return
358+ if payload .get ("error" ) == "workspace is required" :
359+ raise click .ClickException ("Workspace is required" )
360+ plain (payload .get ("path" , "" ))
276361
277362 @engine .command ("dry-run" )
278363 @click .argument ("engine_id" )
@@ -331,9 +416,12 @@ def workspace():
331416 @workspace .command ("inspect" )
332417 @click .argument ("path" , required = False , type = click .Path (exists = False ))
333418 @click .option ("--json" , "as_json" , is_flag = True )
334- def workspace_inspect (path , as_json ):
419+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when the workspace is invalid" )
420+ def workspace_inspect (path , as_json , strict ):
335421 payload = workspace_inspect_payload (_resolve_workspace_path (path or "." ))
336422 if as_json :
423+ if strict and not payload .get ("exists" ):
424+ _emit_and_exit (payload , EXIT_WORKSPACE_INVALID , as_json = True )
337425 _echo_payload (payload , as_json = True )
338426 return
339427 if not payload .get ("exists" ):
@@ -349,13 +437,18 @@ def workspace_inspect(path, as_json):
349437 @workspace .command ("entrypoint" )
350438 @click .argument ("path" , required = False , type = click .Path (exists = False ))
351439 @click .option ("--json" , "as_json" , is_flag = True )
352- def workspace_entrypoint (path , as_json ):
440+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when no entrypoint is resolved" )
441+ def workspace_entrypoint (path , as_json , strict ):
353442 payload = workspace_inspect_payload (_resolve_workspace_path (path or "." ))
354443 result = {"workspace" : payload .get ("workspace" ), "entrypoint" : payload .get ("entrypoint" )}
355444 if as_json :
445+ if strict and not result .get ("entrypoint" ):
446+ _emit_and_exit (result , EXIT_PRECHECK_FAILED , as_json = True )
356447 _echo_payload (result , as_json = True )
357448 return
358449 plain (result ["entrypoint" ] or "" )
450+ if strict and not result .get ("entrypoint" ):
451+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
359452
360453 @workspace .command ("files" )
361454 @click .argument ("path" , required = False , type = click .Path (exists = False ))
@@ -377,9 +470,20 @@ def workspace_files(path, as_json):
377470 @cli .command ("doctor" )
378471 @click .argument ("workspace" , required = False , type = click .Path (exists = False ))
379472 @click .option ("--json" , "as_json" , is_flag = True )
380- def doctor (workspace , as_json ):
473+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when diagnostics detect issues" )
474+ def doctor (workspace , as_json , strict ):
381475 payload = doctor_payload (workspace = _resolve_workspace_path (workspace ))
476+ if strict :
477+ workspace_payload = payload .get ("workspace" )
478+ has_workspace_issue = isinstance (workspace_payload , dict ) and not workspace_payload .get ("exists" , True )
479+ has_engine_issue = payload ["engines" ]["compatible_count" ] != payload ["engines" ]["count" ]
480+ has_qt_issue = not payload .get ("qt_available" , False )
481+ strict_failed = bool (has_workspace_issue or has_engine_issue or has_qt_issue )
482+ else :
483+ strict_failed = False
382484 if as_json :
485+ if strict_failed :
486+ _emit_and_exit (payload , EXIT_PRECHECK_FAILED , as_json = True )
383487 _echo_payload (payload , as_json = True )
384488 return
385489 plain ("PyCompiler ARK Doctor" )
@@ -389,6 +493,38 @@ def doctor(workspace, as_json):
389493 plain (
390494 f" Engines: { payload ['engines' ]['compatible_count' ]} /{ payload ['engines' ]['count' ]} compatible"
391495 )
496+ if strict_failed :
497+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
498+
499+ @cli .group ()
500+ def ci ():
501+ """Run CI-friendly smoke checks."""
502+
503+ @ci .command ("smoke" )
504+ @click .argument ("workspace" , required = False , type = click .Path (exists = False ))
505+ @click .option ("--json" , "as_json" , is_flag = True )
506+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when smoke checks fail" )
507+ @click .option (
508+ "--require-entrypoint" ,
509+ is_flag = True ,
510+ help = "Fail when the workspace has no configured entrypoint" ,
511+ )
512+ def ci_smoke (workspace , as_json , strict , require_entrypoint ):
513+ payload = ci_smoke_payload (
514+ workspace = _resolve_workspace_path (workspace ),
515+ require_entrypoint = require_entrypoint ,
516+ )
517+ if as_json :
518+ if strict and not payload .get ("ok" ):
519+ _emit_and_exit (payload , EXIT_PRECHECK_FAILED , as_json = True )
520+ _echo_payload (payload , as_json = True )
521+ return
522+ plain ("PyCompiler ARK CI Smoke" )
523+ for check in payload .get ("checks" , []):
524+ status = "OK" if check ["ok" ] else "FAIL"
525+ plain (f" [{ status } ] { check ['name' ]} : { check .get ('message' ) or '' } " )
526+ if strict and not payload .get ("ok" ):
527+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
392528
393529 @cli .group ()
394530 def scaffold ():
@@ -437,12 +573,10 @@ def bcasl_gui_cmd(workspace):
437573 @bcasl .command ("list" )
438574 @click .option ("--json" , "as_json" , is_flag = True )
439575 def bcasl_list (as_json ):
440- code = _run_bcasl_headless (["list" ])
441576 if as_json :
442- # Fallback minimal JSON until BCASL headless metadata is fully exposed.
443- _echo_payload ({"status" : "delegated" , "command" : "bcasl list" , "exit_code" : code }, as_json = True )
577+ _echo_payload (bcasl_list_payload (), as_json = True )
444578 return
445- sys .exit (code )
579+ sys .exit (_run_bcasl_headless ([ "list" ]) )
446580
447581 @bcasl .command ("run" )
448582 @click .argument ("workspace" , type = click .Path (exists = False ))
@@ -456,16 +590,20 @@ def bcasl_run(workspace, timeout):
456590 @bcasl .command ("doctor" )
457591 @click .argument ("workspace" , required = False , type = click .Path (exists = False ))
458592 @click .option ("--json" , "as_json" , is_flag = True )
459- def bcasl_doctor (workspace , as_json ):
460- payload = {
461- "workspace" : _resolve_workspace_path (workspace ),
462- "available" : True ,
463- "note" : "BCASL doctor is currently delegated to the headless BCASL runner." ,
464- }
593+ @click .option ("--strict" , is_flag = True , help = "Exit non-zero when BCASL checks fail" )
594+ def bcasl_doctor (workspace , as_json , strict ):
595+ payload = bcasl_doctor_payload (workspace = _resolve_workspace_path (workspace ))
465596 if as_json :
597+ if strict and any (not check ["ok" ] for check in payload .get ("checks" , [])):
598+ _emit_and_exit (payload , EXIT_PRECHECK_FAILED , as_json = True )
466599 _echo_payload (payload , as_json = True )
467600 return
468- plain (payload ["note" ])
601+ plain ("BCASL Doctor" )
602+ for check in payload .get ("checks" , []):
603+ status = "OK" if check ["ok" ] else "FAIL"
604+ plain (f" [{ status } ] { check ['name' ]} : { check .get ('message' ) or '' } " )
605+ if strict and any (not check ["ok" ] for check in payload .get ("checks" , [])):
606+ raise click .exceptions .Exit (EXIT_PRECHECK_FAILED )
469607
470608 @cli .command ("info" )
471609 @click .option ("--json" , "as_json" , is_flag = True )
0 commit comments