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
« 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).
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"""
10from __future__ import annotations
12import logging
13import os
14import sys
15from importlib.metadata import PackageNotFoundError
16from importlib.metadata import version as _get_version
17from typing import Any
19import click
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)
51try:
52 __version__ = _get_version("apcore-cli")
53except PackageNotFoundError:
54 __version__ = "unknown"
56logger = logging.getLogger("apcore_cli")
58EXIT_CONFIG_NOT_FOUND = 47
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
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.
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:
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")
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
148 if app is not None:
149 registry = app.registry
150 executor = app.executor
152 if prog_name is None:
153 prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
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)
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)
175 config = ConfigResolver()
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 )
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
195 if executor is not None and registry is None:
196 raise ValueError("executor requires registry — pass both or neither")
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
205 if _skip_discovery:
206 # Skip filesystem discovery entirely.
207 try:
208 from apcore import Executor as _Executor
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)
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)
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)
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)
246 try:
247 from apcore import Executor as _Executor
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)
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)
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)
273 try:
274 from apcore import Executor as _Executor
275 from apcore import Registry as _Registry
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)
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 )
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)
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)
311 # Wire CliApprovalHandler to Executor (FE-11 §3.5)
312 try:
313 import contextlib
315 from apcore_cli.approval import CliApprovalHandler
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)
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()
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)
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
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 )
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)
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 )
474 # Root-level --help --man support (stays at root per spec §4.1).
475 configure_man_help(cli, prog_name, __version__)
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)
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)
505 return cli
508# ---------------------------------------------------------------------------
509# apcore-toolkit integration (ConventionScanner + BindingLoader + DisplayResolver)
510# ---------------------------------------------------------------------------
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.
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.
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
539 try:
540 from apcore_toolkit import DisplayResolver, RegistryWriter
541 except ImportError:
542 logger.warning("apcore-toolkit not installed — toolkit features unavailable")
543 return
545 scanned: list[Any] = []
547 if commands_dir is not None:
548 try:
549 from apcore_toolkit.convention_scanner import ConventionScanner
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)
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)
576 if not scanned:
577 return
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)
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)
595# ---------------------------------------------------------------------------
596# FE-13 apcli subcommand dispatcher (§4.9)
597# ---------------------------------------------------------------------------
600_ALWAYS_REGISTERED: frozenset[str] = frozenset({"exec"})
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.
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 """
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)
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 ]
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
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
666 registrar()
669# ---------------------------------------------------------------------------
670# FE-13 §11.2 deprecation shims (standalone-mode only)
671# ---------------------------------------------------------------------------
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)
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.
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 """
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)
726 # Tag so extra_commands can recognize and replace shims.
727 shim.__is_deprecation_shim__ = True # type: ignore[attr-defined]
728 return shim
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))