Coverage for src / apcore_cli / factory.py: 85%

267 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-26 10:23 +0800

1"""apcore-cli factory — `create_cli` lives here so it can be imported as a 

2library without pulling in `__main__`'s entry-point semantics (FE-01). 

3 

4This module was extracted from `__main__.py` per audit finding D9 (parallel_impl) 

5so that downstream projects can `from apcore_cli import create_cli` (or 

6`from apcore_cli.factory import create_cli`) without importing the binary 

7entry-point script module. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13import os 

14import sys 

15from importlib.metadata import PackageNotFoundError 

16from importlib.metadata import version as _get_version 

17from typing import Any 

18 

19import click 

20 

21from apcore_cli.builtin_group import ( 

22 RESERVED_GROUP_NAMES, 

23 ApcliGroup, 

24) 

25from apcore_cli.cli import GroupedModuleGroup, set_audit_logger, set_verbose_help 

26from apcore_cli.config import ConfigResolver 

27from apcore_cli.discovery import ( 

28 register_describe_command, 

29 register_exec_command, 

30 register_list_command, 

31 register_validate_command, 

32) 

33from apcore_cli.exposure import ExposureFilter 

34from apcore_cli.init_cmd import register_init_command 

35from apcore_cli.security.audit import AuditLogger 

36from apcore_cli.shell import ( 

37 configure_man_help, 

38 register_completion_command, 

39) 

40from apcore_cli.strategy import register_pipeline_command 

41from apcore_cli.system_cmd import ( 

42 _system_modules_available, 

43 register_config_command, 

44 register_disable_command, 

45 register_enable_command, 

46 register_health_command, 

47 register_reload_command, 

48 register_usage_command, 

49) 

50 

51try: 

52 __version__ = _get_version("apcore-cli") 

53except PackageNotFoundError: 

54 __version__ = "unknown" 

55 

56logger = logging.getLogger("apcore_cli") 

57 

58EXIT_CONFIG_NOT_FOUND = 47 

59 

60 

61def _has_verbose_flag(argv: list[str] | None = None) -> bool: 

62 """Check if --verbose is present in argv (pre-parse, before Click).""" 

63 args = argv if argv is not None else sys.argv[1:] 

64 return "--verbose" in args 

65 

66 

67def create_cli( 

68 extensions_dir: str | None = None, 

69 prog_name: str | None = None, 

70 commands_dir: str | None = None, 

71 binding_path: str | None = None, 

72 registry: Any | None = None, 

73 executor: Any | None = None, 

74 extra_commands: list[Any] | None = None, 

75 app: Any | None = None, 

76 expose: dict | ExposureFilter | None = None, 

77 apcli: bool | dict | ApcliGroup | None = None, 

78 allowed_prefixes: list[str] | None = None, 

79) -> click.Group: 

80 """Create the CLI application. 

81 

82 Args: 

83 extensions_dir: Override for extensions directory. 

84 When None, resolves via ConfigResolver (env/file/default). 

85 prog_name: Name shown in help text and version output. 

86 Defaults to the basename of sys.argv[0], so downstream projects 

87 that install their own entry-point script get the correct name 

88 automatically (e.g. ``mycli`` instead of ``apcore-cli``). 

89 commands_dir: Directory containing convention-based modules. 

90 When set, scans for plain-function modules and registers 

91 them via ConventionScanner (requires apcore-toolkit). 

92 binding_path: Path to binding.yaml file or directory for display resolution. 

93 When set, applies DisplayResolver to convention-scanned modules 

94 (requires apcore-toolkit). 

95 registry: Pre-populated apcore Registry instance. When provided, skips 

96 filesystem discovery entirely. Useful for frameworks that register 

97 modules at runtime (e.g. apflow's bridge). 

98 executor: Pre-built apcore Executor instance. When provided alongside 

99 registry, skips Executor construction. If omitted but registry 

100 is provided, an Executor is built from the given registry. 

101 extra_commands: Extra Click commands to add to the CLI root (FE-11 §3.11). 

102 Names must not collide with RESERVED_GROUP_NAMES (i.e. 'apcli') 

103 or any existing top-level command. Collisions with deprecation 

104 shims are detected and raise ValueError. Note: BUILTIN_COMMANDS 

105 was retired in v0.7.0; see cli.py for the retirement notice. 

106 app: APCore unified client (apcore >= 0.18.0). Mutually exclusive with 

107 registry/executor. When provided, registry and executor are extracted 

108 from app.registry and app.executor. Filesystem discovery is skipped 

109 if app.registry already has registered modules; otherwise discovery 

110 proceeds into app.registry. Note: ext_dir validation still runs when 

111 the app registry is empty (discovery fallthrough path). 

112 expose: Module exposure filter (FE-12). Accepts an ExposureFilter instance 

113 or a dict that ExposureFilter.from_config can parse. 

114 allowed_prefixes: Optional allowlist of module-path prefixes forwarded 

115 to :meth:`apcore_toolkit.RegistryWriter.write` when 

116 registering convention-scanned or binding-loaded 

117 modules. When set, ``resolve_target`` rejects any 

118 ``target:`` path outside the listed prefixes 

119 *before* calling ``importlib.import_module`` — 

120 mitigates arbitrary-code-execution via forged 

121 binding YAML (e.g. ``target: "os:system"``). 

122 Mirrors the TypeScript SDK's ``allowedPrefixes`` 

123 option. Also settable from the CLI via the 

124 repeatable ``--allowed-prefix`` flag (standalone 

125 mode only). 

126 apcli: Built-in ``apcli`` group configuration (FE-13). Accepts: 

127 

128 * ``True`` / ``False`` — shorthand for ``{mode: "all"}`` / 

129 ``{mode: "none"}``. 

130 * A dict matching the ``ApcliConfig`` schema 

131 (``{"mode": "...", "include": [...], "exclude": [...], 

132 "disable_env": bool}``). 

133 * A pre-built :class:`~apcore_cli.builtin_group.ApcliGroup` 

134 instance (Tier 1 override; bypasses env var + yaml). 

135 * ``None`` — falls back to ``apcore.yaml``'s ``apcli:`` block, 

136 then ``APCORE_CLI_APCLI`` env var, then auto-detect 

137 (standalone → visible, embedded → hidden). 

138 """ 

139 if app is not None and (registry is not None or executor is not None): 

140 raise ValueError("app is mutually exclusive with registry/executor") 

141 

142 # FE-13 FR-13-13: lock in "registry was caller-injected" BEFORE filesystem 

143 # discovery may assign `registry` to a freshly constructed Registry. This 

144 # drives both the auto-detect default (embedded → apcli hidden) and the 

145 # gating of the --extensions-dir/--commands-dir/--binding root flags. 

146 registry_injected = registry is not None or app is not None 

147 

148 if app is not None: 

149 registry = app.registry 

150 executor = app.executor 

151 

152 if prog_name is None: 

153 prog_name = os.path.basename(sys.argv[0]) or "apcore-cli" 

154 

155 # Pre-parse --verbose before Click runs so build_module_command knows 

156 # whether to hide built-in options. 

157 verbose = _has_verbose_flag() 

158 set_verbose_help(verbose) 

159 

160 # Resolve CLI log level (3-tier precedence, evaluated before Click runs): 

161 # APCORE_CLI_LOGGING_LEVEL (CLI-specific) > APCORE_LOGGING_LEVEL (global) > WARNING 

162 # The --log-level flag (parsed later) can further override at runtime. 

163 _cli_level_str = os.environ.get("APCORE_CLI_LOGGING_LEVEL", "").upper() 

164 _global_level_str = os.environ.get("APCORE_LOGGING_LEVEL", "").upper() 

165 _active_level_str = _cli_level_str or _global_level_str 

166 _default_level = getattr(logging, _active_level_str, logging.WARNING) if _active_level_str else logging.WARNING 

167 logging.basicConfig(level=_default_level, format="%(levelname)s: %(message)s") 

168 # basicConfig is a no-op if handlers already exist; always set the root level explicitly. 

169 logging.getLogger().setLevel(_default_level) 

170 # Silence noisy upstream apcore loggers unless the user requests verbose output. 

171 # Always set explicitly so the level is deterministic regardless of prior state. 

172 apcore_level = _default_level if _default_level <= logging.INFO else logging.ERROR 

173 logging.getLogger("apcore").setLevel(apcore_level) 

174 

175 config = ConfigResolver() 

176 

177 if extensions_dir is not None: 

178 ext_dir = extensions_dir 

179 else: 

180 ext_dir = config.resolve( 

181 "extensions.root", 

182 cli_flag="--extensions-dir", 

183 env_var="APCORE_EXTENSIONS_ROOT", 

184 ) 

185 

186 help_text_max_length = config.resolve( 

187 "cli.help_text_max_length", 

188 env_var="APCORE_CLI_HELP_TEXT_MAX_LENGTH", 

189 ) 

190 try: 

191 help_text_max_length = int(help_text_max_length) 

192 except (TypeError, ValueError): 

193 help_text_max_length = 1000 

194 

195 if executor is not None and registry is None: 

196 raise ValueError("executor requires registry — pass both or neither") 

197 

198 if registry is not None: 

199 # Pre-populated registry provided. 

200 # When called via app=, skip discovery only if the registry already has modules; 

201 # otherwise fall through to filesystem discovery into the provided registry. 

202 _app_registry_has_modules = (app is not None) and len(list(registry.list())) > 0 

203 _skip_discovery = (app is None) or _app_registry_has_modules 

204 

205 if _skip_discovery: 

206 # Skip filesystem discovery entirely. 

207 try: 

208 from apcore import Executor as _Executor 

209 

210 if executor is None: 

211 executor = _Executor(registry) 

212 logger.info("Using pre-populated registry (%d modules).", len(list(registry.list()))) 

213 except Exception as e: 

214 click.echo( 

215 f"Error: Failed to initialize executor from provided registry: {e}", 

216 err=True, 

217 ) 

218 sys.exit(EXIT_CONFIG_NOT_FOUND) 

219 else: 

220 # app= was provided but registry is empty — run discovery into app.registry. 

221 ext_dir_missing = not os.path.exists(ext_dir) 

222 ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK) 

223 

224 if ext_dir_missing: 

225 click.echo( 

226 f"Error: Extensions directory not found: '{ext_dir}'." 

227 " Set APCORE_EXTENSIONS_ROOT or verify the path.", 

228 err=True, 

229 ) 

230 sys.exit(EXIT_CONFIG_NOT_FOUND) 

231 

232 if ext_dir_unreadable: 

233 click.echo( 

234 f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.", 

235 err=True, 

236 ) 

237 sys.exit(EXIT_CONFIG_NOT_FOUND) 

238 

239 try: 

240 logger.debug("Loading extensions from %s (into app.registry)", ext_dir) 

241 count = registry.discover() 

242 logger.info("Initialized apcore-cli with %d modules (via app.registry).", count) 

243 except Exception as e: 

244 logger.warning("Discovery failed: %s", e) 

245 

246 try: 

247 from apcore import Executor as _Executor 

248 

249 if executor is None: 

250 executor = _Executor(registry) 

251 except Exception as e: 

252 click.echo(f"Error: Failed to initialize executor from app.registry: {e}", err=True) 

253 sys.exit(EXIT_CONFIG_NOT_FOUND) 

254 else: 

255 # Standard path: discover modules from filesystem. 

256 ext_dir_missing = not os.path.exists(ext_dir) 

257 ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK) 

258 

259 if ext_dir_missing: 

260 click.echo( 

261 f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.", 

262 err=True, 

263 ) 

264 sys.exit(EXIT_CONFIG_NOT_FOUND) 

265 

266 if ext_dir_unreadable: 

267 click.echo( 

268 f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.", 

269 err=True, 

270 ) 

271 sys.exit(EXIT_CONFIG_NOT_FOUND) 

272 

273 try: 

274 from apcore import Executor as _Executor 

275 from apcore import Registry as _Registry 

276 

277 registry = _Registry(extensions_dir=ext_dir) 

278 try: 

279 logger.debug("Loading extensions from %s", ext_dir) 

280 count = registry.discover() 

281 logger.info("Initialized apcore-cli with %d modules.", count) 

282 except Exception as e: 

283 logger.warning("Discovery failed: %s", e) 

284 

285 # Toolkit integration: convention scanner + binding loader. 

286 # Split into a dedicated helper so the three SDK entry points 

287 # (ConventionScanner, BindingLoader, DisplayResolver) can be 

288 # composed cleanly — this brings the Python CLI to parity with 

289 # ``../apcore-cli-typescript/src/main.ts::loadBindingDisplayOverlay`` 

290 # which wires BindingLoader through when only ``binding_path`` is 

291 # supplied (previously a no-op in the Python CLI). 

292 _apply_toolkit_integration( 

293 registry, 

294 commands_dir=commands_dir, 

295 binding_path=binding_path, 

296 allowed_prefixes=allowed_prefixes, 

297 ) 

298 

299 executor = _Executor(registry) 

300 except Exception as e: 

301 click.echo(f"Error: Failed to initialize registry: {e}", err=True) 

302 sys.exit(EXIT_CONFIG_NOT_FOUND) 

303 

304 # Initialize audit logger 

305 try: 

306 audit_logger = AuditLogger() 

307 set_audit_logger(audit_logger) 

308 except Exception as e: 

309 logger.warning("Failed to initialize audit logger: %s", e) 

310 

311 # Wire CliApprovalHandler to Executor (FE-11 §3.5) 

312 try: 

313 import contextlib 

314 

315 from apcore_cli.approval import CliApprovalHandler 

316 

317 approval_timeout = 60 

318 with contextlib.suppress(TypeError, ValueError): 

319 approval_timeout = int(config.resolve("cli.approval_timeout", env_var="APCORE_CLI_APPROVAL_TIMEOUT") or 60) 

320 handler = CliApprovalHandler(auto_approve=False, timeout=approval_timeout) 

321 if hasattr(executor, "set_approval_handler"): 

322 executor.set_approval_handler(handler) 

323 logger.debug("CliApprovalHandler wired to Executor (timeout=%ds).", approval_timeout) 

324 except Exception as e: 

325 # Surface at WARNING so a misconfigured apcore runtime (e.g., signature 

326 # drift in set_approval_handler) doesn't silently skip the gate — modules 

327 # with requires_approval=True would otherwise execute with no prompt. 

328 logger.warning("Failed to wire CliApprovalHandler: %s", e) 

329 

330 # Build exposure filter (FE-12) 

331 if isinstance(expose, ExposureFilter): 

332 exposure_filter = expose 

333 elif isinstance(expose, dict): 

334 exposure_filter = ExposureFilter.from_config({"expose": expose}) 

335 else: 

336 expose_mode = config.resolve("expose.mode", env_var="APCORE_CLI_EXPOSE_MODE") 

337 expose_include = config.resolve("expose.include") 

338 expose_exclude = config.resolve("expose.exclude") 

339 if expose_mode and expose_mode != "all": 

340 exposure_filter = ExposureFilter.from_config( 

341 { 

342 "expose": { 

343 "mode": expose_mode, 

344 "include": expose_include or [], 

345 "exclude": expose_exclude or [], 

346 } 

347 } 

348 ) 

349 else: 

350 exposure_filter = ExposureFilter() 

351 

352 # Build ApcliGroup (FE-13 §4.8) via 3-source dispatch: 

353 # 1) pre-built ApcliGroup instance (pass-through), 

354 # 2) CliConfig bool/dict (Tier 1 — wins over env + yaml), 

355 # 3) apcore.yaml via ConfigResolver.resolve_object (Tier 3). 

356 try: 

357 if isinstance(apcli, ApcliGroup): 

358 apcli_cfg = apcli 

359 elif isinstance(apcli, bool | dict): 

360 apcli_cfg = ApcliGroup.from_cli_config(apcli, registry_injected=registry_injected) 

361 elif apcli is None: 

362 yaml_val = config.resolve_object("apcli") 

363 apcli_cfg = ApcliGroup.from_yaml(yaml_val, registry_injected=registry_injected) 

364 else: 

365 raise TypeError(f"apcli: expected bool, dict, ApcliGroup, or None; got {type(apcli).__name__}") 

366 except TypeError as e: 

367 click.echo(f"Error: {e}", err=True) 

368 sys.exit(2) 

369 

370 @click.group( 

371 cls=GroupedModuleGroup, 

372 registry=registry, 

373 executor=executor, 

374 help_text_max_length=help_text_max_length, 

375 exposure_filter=exposure_filter, 

376 extensions_root=ext_dir, 

377 name=prog_name, 

378 help="CLI adapter for the apcore module ecosystem.", 

379 ) 

380 @click.version_option( 

381 version=__version__, 

382 prog_name=prog_name, 

383 ) 

384 @click.option( 

385 "--log-level", 

386 default=None, 

387 type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), 

388 help="Log verbosity. Overrides APCORE_CLI_LOGGING_LEVEL and APCORE_LOGGING_LEVEL env vars.", 

389 ) 

390 @click.option( 

391 "--verbose", 

392 "verbose_help", 

393 is_flag=True, 

394 default=False, 

395 help="Show all options in help output (including built-in apcore options).", 

396 ) 

397 @click.pass_context 

398 def cli( 

399 ctx: click.Context, 

400 log_level: str | None = None, 

401 verbose_help: bool = False, 

402 **_discovery_opts: Any, # --extensions-dir/--commands-dir/--binding when standalone 

403 ) -> None: 

404 if log_level is not None: 

405 level = getattr(logging, log_level.upper(), logging.WARNING) 

406 logging.getLogger().setLevel(level) 

407 apcore_level = level if level <= logging.INFO else logging.ERROR 

408 logging.getLogger("apcore").setLevel(apcore_level) 

409 ctx.ensure_object(dict) 

410 ctx.obj["extensions_dir"] = ext_dir 

411 ctx.obj["verbose_help"] = verbose_help 

412 ctx.obj["exposure_filter"] = exposure_filter 

413 

414 # FE-13 §4.1 / FR-13-13: --extensions-dir, --commands-dir, --binding are 

415 # registered only in standalone mode. When a registry is injected they 

416 # are inert (the discovery path doesn't run) so omitting them removes 

417 # help-text noise AND surfaces a clear "unknown option" error for the 

418 # rare caller who still tries them. 

419 if not registry_injected: 

420 cli.params.extend( 

421 [ 

422 click.Option( 

423 ["--extensions-dir", "extensions_dir_opt"], 

424 default=None, 

425 help="Path to apcore extensions directory.", 

426 ), 

427 click.Option( 

428 ["--commands-dir", "commands_dir_opt"], 

429 default=None, 

430 help="Path to convention-based commands directory.", 

431 expose_value=False, 

432 ), 

433 click.Option( 

434 ["--binding", "binding_opt"], 

435 default=None, 

436 help="Path to binding.yaml file or directory for display resolution.", 

437 expose_value=False, 

438 ), 

439 click.Option( 

440 ["--allowed-prefix", "allowed_prefix_opt"], 

441 multiple=True, 

442 default=None, 

443 help=( 

444 "Allowlist of module-path prefixes for binding/convention " 

445 "``target:`` resolution (repeatable). Forwarded to " 

446 "RegistryWriter.write(allowed_prefixes=...). Mitigates " 

447 "code-exec via forged binding YAML." 

448 ), 

449 expose_value=False, 

450 ), 

451 ] 

452 ) 

453 

454 # Build the apcli sub-group. `hidden` controls root --help rendering only 

455 # (spec §4.1 / §4.11): the group and its subcommands remain reachable via 

456 # `<cli> apcli ...` regardless. 

457 apcli_group = click.Group( 

458 name="apcli", 

459 help="apcore-cli built-in commands.", 

460 hidden=not apcli_cfg.is_group_visible(), 

461 ) 

462 cli.add_command(apcli_group) 

463 

464 # Dispatch the 13-entry subcommand registrar table (FE-13 §4.9). 

465 _register_apcli_subcommands( 

466 apcli_group, 

467 apcli_cfg, 

468 registry, 

469 executor, 

470 exposure_filter, 

471 prog_name, 

472 ) 

473 

474 # Root-level --help --man support (stays at root per spec §4.1). 

475 configure_man_help(cli, prog_name, __version__) 

476 

477 # FE-13 §11.2 deprecation shims — standalone mode only. Embedded 

478 # integrators' end users never see apcore-cli deprecation warnings. 

479 if not registry_injected: 

480 _register_deprecation_shims(cli, apcli_group, prog_name) 

481 

482 # Extra commands from downstream projects (FE-11 §3.11). Reserved-name 

483 # (`apcli`) collisions are hard-rejected. Collisions with deprecation 

484 # shims yield to the user-supplied command (shim is dropped with a warning) 

485 # — shims are transitional scaffolding, not a real collision. 

486 if extra_commands: 

487 for cmd in extra_commands: 

488 cmd_name = getattr(cmd, "name", None) 

489 if cmd_name and cmd_name in RESERVED_GROUP_NAMES: 

490 msg = f"Extra command '{cmd_name}' is reserved." 

491 raise ValueError(msg) 

492 if cmd_name and cmd_name in cli.commands: 

493 existing = cli.commands[cmd_name] 

494 if getattr(existing, "__is_deprecation_shim__", False): 

495 logger.warning( 

496 "extra_commands '%s' overrides the deprecation shim for the same name.", 

497 cmd_name, 

498 ) 

499 del cli.commands[cmd_name] 

500 else: 

501 msg = f"Extra command '{cmd_name}' conflicts with an existing command." 

502 raise ValueError(msg) 

503 cli.add_command(cmd) 

504 

505 return cli 

506 

507 

508# --------------------------------------------------------------------------- 

509# apcore-toolkit integration (ConventionScanner + BindingLoader + DisplayResolver) 

510# --------------------------------------------------------------------------- 

511 

512 

513def _apply_toolkit_integration( 

514 registry: Any, 

515 *, 

516 commands_dir: str | None, 

517 binding_path: str | None, 

518 allowed_prefixes: list[str] | None, 

519) -> None: 

520 """Load convention-scanner and/or binding-loader modules into the registry. 

521 

522 Mirrors the TypeScript ``applyToolkitIntegration`` + 

523 ``loadBindingDisplayOverlay`` pair 

524 (``../apcore-cli-typescript/src/main.ts:706-781``). Both sources of 

525 module metadata (Python ``ConventionScanner`` for in-code modules, 

526 ``BindingLoader`` for ``.binding.yaml`` files) are parsed into 

527 ``ScannedModule`` lists, enriched with display overlay via 

528 ``DisplayResolver``, then written through ``RegistryWriter`` — the 

529 single registration path ensures ``--allowed-prefix`` protection 

530 applies to both sources consistently. 

531 

532 Silently no-op if ``apcore-toolkit`` is not installed; individual 

533 optional features (``BindingLoader`` in toolkit < 0.5) degrade to 

534 a WARNING and are skipped. 

535 """ 

536 if commands_dir is None and binding_path is None: 

537 return 

538 

539 try: 

540 from apcore_toolkit import DisplayResolver, RegistryWriter 

541 except ImportError: 

542 logger.warning("apcore-toolkit not installed — toolkit features unavailable") 

543 return 

544 

545 scanned: list[Any] = [] 

546 

547 if commands_dir is not None: 

548 try: 

549 from apcore_toolkit.convention_scanner import ConventionScanner 

550 

551 scanner = ConventionScanner() 

552 scanned.extend(scanner.scan(commands_dir)) 

553 except Exception as e: 

554 logger.warning("Convention scanner failed on '%s': %s", commands_dir, e) 

555 

556 if binding_path is not None: 

557 try: 

558 from apcore_toolkit import BindingLoader 

559 except ImportError: 

560 # apcore-toolkit < 0.5.0 — silently skip the overlay (parity with 

561 # TS main.ts:761 "apcore-toolkit < 0.5.0 (no BindingLoader)"). 

562 logger.warning("apcore-toolkit < 0.5.0: BindingLoader unavailable, --binding skipped") 

563 else: 

564 try: 

565 loader = BindingLoader() 

566 loaded = loader.load(binding_path) 

567 scanned.extend(loaded) 

568 logger.info( 

569 "BindingLoader: parsed %d module(s) from %s", 

570 len(loaded), 

571 binding_path, 

572 ) 

573 except Exception as e: 

574 logger.warning("BindingLoader failed on '%s': %s", binding_path, e) 

575 

576 if not scanned: 

577 return 

578 

579 if binding_path is not None: 

580 try: 

581 resolver = DisplayResolver() 

582 scanned = resolver.resolve(scanned, binding_path=binding_path) 

583 logger.debug("DisplayResolver: applied binding overlay from %s", binding_path) 

584 except Exception as e: 

585 logger.warning("DisplayResolver failed: %s", e) 

586 

587 try: 

588 writer = RegistryWriter() 

589 writer.write(scanned, registry, allowed_prefixes=allowed_prefixes) 

590 logger.info("RegistryWriter: registered %d toolkit-sourced module(s)", len(scanned)) 

591 except Exception as e: 

592 logger.warning("RegistryWriter failed: %s", e) 

593 

594 

595# --------------------------------------------------------------------------- 

596# FE-13 apcli subcommand dispatcher (§4.9) 

597# --------------------------------------------------------------------------- 

598 

599 

600_ALWAYS_REGISTERED: frozenset[str] = frozenset({"exec"}) 

601 

602 

603def _register_apcli_subcommands( 

604 apcli_group: click.Group, 

605 apcli_cfg: ApcliGroup, 

606 registry: Any, 

607 executor: Any, 

608 exposure_filter: ExposureFilter, 

609 prog_name: str, 

610) -> None: 

611 """Register the 13 canonical apcli subcommands, filtered by visibility. 

612 

613 Mirrors ``_registerApcliSubcommands`` in 

614 ``../apcore-cli-typescript/src/main.ts``. Each entry declares whether it 

615 ``requires_executor``; when the executor is missing, the entry is skipped 

616 silently (unless it's in :data:`_ALWAYS_REGISTERED` — in that case a WARN 

617 is emitted since spec §4.9 guarantees registration). 

618 """ 

619 

620 # Build system-subcommand registrars only when the executor's registry 

621 # carries `system.*` modules. Outside standalone+system-modules deploys, 

622 # invoking the subcommands would error at runtime with an opaque message; 

623 # probing here keeps `<cli> apcli --help` lean in the common case. 

624 system_available = executor is not None and _system_modules_available(executor) 

625 

626 # Each entry: (name, requires_executor, callable that registers the 

627 # subcommand on apcli_group). The apcli_group is captured per entry. 

628 registrars: list[tuple[str, bool, Any]] = [ 

629 ("list", False, lambda: register_list_command(apcli_group, registry, exposure_filter)), 

630 ("describe", False, lambda: register_describe_command(apcli_group, registry)), 

631 ("exec", True, lambda: register_exec_command(apcli_group, registry, executor)), 

632 ("validate", True, lambda: register_validate_command(apcli_group, registry, executor)), 

633 ("init", False, lambda: register_init_command(apcli_group)), 

634 ("health", True, lambda: register_health_command(apcli_group, executor) if system_available else None), 

635 ("usage", True, lambda: register_usage_command(apcli_group, executor) if system_available else None), 

636 ("enable", True, lambda: register_enable_command(apcli_group, executor) if system_available else None), 

637 ("disable", True, lambda: register_disable_command(apcli_group, executor) if system_available else None), 

638 ("reload", True, lambda: register_reload_command(apcli_group, executor) if system_available else None), 

639 ("config", True, lambda: register_config_command(apcli_group, executor) if system_available else None), 

640 ("completion", False, lambda: register_completion_command(apcli_group, prog_name=prog_name)), 

641 ("describe-pipeline", True, lambda: register_pipeline_command(apcli_group, executor)), 

642 ] 

643 

644 mode = apcli_cfg.resolve_visibility() 

645 for name, requires_executor, registrar in registrars: 

646 # Decide whether this entry registers. Compute BEFORE the 

647 # missing-executor skip so _ALWAYS_REGISTERED is honored even when 

648 # its requires_executor flag is True — missing executor then warns 

649 # rather than silently drops (spec §4.9). 

650 if mode in ("all", "none"): 

651 should_register = True 

652 else: 

653 should_register = name in _ALWAYS_REGISTERED or apcli_cfg.is_subcommand_included(name) 

654 if not should_register: 

655 continue 

656 

657 if requires_executor and executor is None: 

658 if name in _ALWAYS_REGISTERED: 

659 logger.warning( 

660 "apcli.%s is always-registered but no executor is wired — " 

661 "subcommand unavailable. Pass executor to create_cli().", 

662 name, 

663 ) 

664 continue 

665 

666 registrar() 

667 

668 

669# --------------------------------------------------------------------------- 

670# FE-13 §11.2 deprecation shims (standalone-mode only) 

671# --------------------------------------------------------------------------- 

672 

673 

674_DEPRECATED_ROOT_COMMANDS: tuple[str, ...] = ( 

675 "list", 

676 "describe", 

677 "exec", 

678 "init", 

679 "validate", 

680 "health", 

681 "usage", 

682 "enable", 

683 "disable", 

684 "reload", 

685 "config", 

686 "completion", 

687 "describe-pipeline", 

688) 

689 

690 

691def _register_deprecation_shims( 

692 root: click.Group, 

693 apcli_group: click.Group, 

694 prog_name: str, 

695) -> None: 

696 """Register thin root-level shims that forward to the ``apcli`` subcommand. 

697 

698 Each shim writes the spec §11.2 warning to stderr then re-enters Click's 

699 dispatch loop on the ``apcli <name>`` path, preserving positional args + 

700 options. The shim is tagged with ``__is_deprecation_shim__ = True`` so 

701 ``extra_commands`` can override without raising a collision error. 

702 """ 

703 

704 def _make_shim(name: str, sub: click.Command) -> click.Command: 

705 @click.command( 

706 name=name, 

707 context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, 

708 help=f"[DEPRECATED] Use '{prog_name} apcli {name}' instead.", 

709 hidden=True, 

710 add_help_option=False, 

711 ) 

712 @click.pass_context 

713 def shim(ctx: click.Context) -> None: 

714 click.echo( 

715 f"WARNING: '{name}' as a root-level command is deprecated. " 

716 f"Use '{prog_name} apcli {name}' instead.\n" 

717 f" Will be removed in v0.8. See: " 

718 f"https://aiperceivable.github.io/apcore-cli/features/builtin-group/#11-migration", 

719 err=True, 

720 ) 

721 # Forward remaining args to the apcli subcommand's own invocation 

722 # path so nested sub-subcommands (`config get foo`) route correctly. 

723 tail = list(ctx.args) 

724 sub.main(args=tail, prog_name=f"{prog_name} apcli {name}", standalone_mode=False) 

725 

726 # Tag so extra_commands can recognize and replace shims. 

727 shim.__is_deprecation_shim__ = True # type: ignore[attr-defined] 

728 return shim 

729 

730 for name in _DEPRECATED_ROOT_COMMANDS: 

731 sub = apcli_group.commands.get(name) 

732 if sub is None: 

733 continue 

734 if name in root.commands: 

735 continue 

736 root.add_command(_make_shim(name, sub))