| #!/usr/bin/env fuchsia-vendored-python | |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """CLI for fx subcommand resolution, PATH computation, and help rendering.""" | |
| import argparse | |
| import os | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass | |
| from enum import StrEnum | |
| from pathlib import Path | |
| from typing import Any | |
| try: | |
| from fx_cli.registry import ( | |
| Category, | |
| RegistryEntry, | |
| RegistryError, | |
| load_registry_file, | |
| resolve_fuchsia_dir, | |
| ) | |
| except ImportError: | |
| from registry import ( | |
| Category, | |
| RegistryEntry, | |
| RegistryError, | |
| load_registry_file, | |
| resolve_fuchsia_dir, | |
| ) | |
| class CommandKind(StrEnum): | |
| REGISTERED_SCRIPT = "registered_script" | |
| REGISTERED_TARGET = "registered_target" | |
| LEGACY_SCRIPT = "legacy_script" | |
| LEGACY_FX_HOST_TOOL = "legacy_fx_host_tool" | |
| NOT_FOUND = "not_found" | |
| class ResolutionMode(StrEnum): | |
| SCRIPT = "script" | |
| TARGET = "target" | |
| LEGACY = "legacy" | |
| LEGACY_FX = "legacy_fx" | |
| LEGACY_HOST_TOOL = "legacy_host_tool" | |
| NONE = "none" | |
| @dataclass | |
| class ResolvedCommand: | |
| """Result of 5-level vendor-first fx subcommand resolution.""" | |
| kind: CommandKind | |
| vendor: str = "" | |
| target: str = "" | |
| script: str = "" | |
| exec_path: str = "" | |
| inject_prebuilt_paths: int = 0 | |
| category: str = Category.OTHER | |
| summary: str = "" | |
| contrib: int = 0 | |
| deprecated: int = 0 | |
| source: str = "" | |
| spec: str = "" | |
| mode: ResolutionMode = ResolutionMode.NONE | |
| @classmethod | |
| def from_registry_entry( | |
| cls, | |
| fuchsia_dir: Path, | |
| entry: RegistryEntry, | |
| vendor: str = "", | |
| source: Path | None = None, | |
| ) -> "ResolvedCommand": | |
| is_script = entry.is_script | |
| target_spec = entry.spec | |
| exec_path = ( | |
| str(fuchsia_dir / (entry.script or "")[2:]) | |
| if is_script | |
| else target_spec | |
| ) | |
| return cls( | |
| kind=( | |
| CommandKind.REGISTERED_SCRIPT | |
| if is_script | |
| else CommandKind.REGISTERED_TARGET | |
| ), | |
| vendor=vendor, | |
| target=entry.target or "", | |
| script=entry.script or "", | |
| exec_path=exec_path, | |
| inject_prebuilt_paths=1 if entry.inject_prebuilt_paths else 0, | |
| category=entry.category, | |
| summary=entry.summary, | |
| contrib=1 if entry.contrib else 0, | |
| deprecated=1 if entry.deprecated else 0, | |
| source=str(source) if source else (entry.source or ""), | |
| spec=target_spec, | |
| mode=ResolutionMode.SCRIPT if is_script else ResolutionMode.TARGET, | |
| ) | |
| @classmethod | |
| def not_found(cls) -> "ResolvedCommand": | |
| return cls(kind=CommandKind.NOT_FOUND) | |
| def to_dict(self) -> dict[str, Any]: | |
| if self.kind == CommandKind.NOT_FOUND: | |
| return {"kind": self.kind} | |
| return { | |
| "kind": self.kind, | |
| "vendor": self.vendor, | |
| "target": self.target, | |
| "script": self.script, | |
| "exec_path": self.exec_path, | |
| "inject_prebuilt_paths": self.inject_prebuilt_paths, | |
| "category": self.category, | |
| "summary": self.summary, | |
| "contrib": self.contrib, | |
| "deprecated": self.deprecated, | |
| "source": self.source, | |
| "spec": self.spec, | |
| "mode": self.mode, | |
| } | |
| def __getitem__(self, key: str) -> Any: | |
| d = self.to_dict() | |
| if key in d: | |
| return d[key] | |
| raise KeyError(key) | |
| def get(self, key: str, default: Any = None) -> Any: | |
| return self.to_dict().get(key, default) | |
| def __contains__(self, key: str) -> bool: | |
| return key in self.to_dict() | |
| def keys(self) -> Any: | |
| return self.to_dict().keys() | |
| def resolve_command( | |
| fuchsia_dir: Path, | |
| cmd_name: str, | |
| build_dir: Path | None = None, | |
| vendor_name: str | None = None, | |
| ) -> ResolvedCommand: | |
| vendor_root = fuchsia_dir / "vendor" | |
| # Level 1: Vendor registered tools | |
| if vendor_name: | |
| vd = vendor_root / vendor_name | |
| v_reg = vd / "scripts" / "devshell" / "fx_host_tools.json" | |
| v_entries = load_registry_file( | |
| v_reg, source_label=f"vendor/{vendor_name}" | |
| ) | |
| if cmd_name in v_entries: | |
| return ResolvedCommand.from_registry_entry( | |
| fuchsia_dir, | |
| v_entries[cmd_name], | |
| vendor=vendor_name, | |
| source=v_reg, | |
| ) | |
| elif vendor_root.is_dir(): | |
| matched_vendor_entries: list[tuple[str, RegistryEntry, Path]] = [] | |
| for vd in sorted(vendor_root.iterdir(), key=lambda p: p.name): | |
| if vd.is_dir(): | |
| v_reg = vd / "scripts" / "devshell" / "fx_host_tools.json" | |
| v_entries = load_registry_file( | |
| v_reg, source_label=f"vendor/{vd.name}" | |
| ) | |
| if cmd_name in v_entries: | |
| matched_vendor_entries.append( | |
| (vd.name, v_entries[cmd_name], v_reg) | |
| ) | |
| if len(matched_vendor_entries) > 1: | |
| matched_names = [v for v, _, _ in matched_vendor_entries] | |
| raise RegistryError( | |
| f"Command '{cmd_name}' is registered in multiple vendor repositories " | |
| f"({' '.join(matched_names)}). Run 'fx vendor <vendor> {cmd_name}' instead." | |
| ) | |
| elif len(matched_vendor_entries) == 1: | |
| v_name, entry, v_reg = matched_vendor_entries[0] | |
| return ResolvedCommand.from_registry_entry( | |
| fuchsia_dir, entry, vendor=v_name, source=v_reg | |
| ) | |
| # Level 2: Vendor legacy scripts / .fx metadata | |
| vendor_dirs: list[Path] = [] | |
| if vendor_name: | |
| vpath = vendor_root / vendor_name | |
| if vpath.is_dir(): | |
| vendor_dirs.append(vpath) | |
| elif vendor_root.is_dir(): | |
| for vd in sorted(vendor_root.iterdir(), key=lambda p: p.name): | |
| if vd.is_dir(): | |
| vendor_dirs.append(vd) | |
| for vd in vendor_dirs: | |
| for subdir in ("scripts/devshell", "scripts/devshell/contrib"): | |
| dir_path = vd / subdir | |
| if not dir_path.is_dir(): | |
| continue | |
| cand = dir_path / cmd_name | |
| if cand.is_file() and os.access(cand, os.X_OK): | |
| return ResolvedCommand( | |
| kind=CommandKind.LEGACY_SCRIPT, | |
| vendor=vd.name, | |
| script=str(cand), | |
| exec_path=str(cand), | |
| inject_prebuilt_paths=1, | |
| category=Category.OTHER, | |
| contrib=1 if "contrib" in subdir else 0, | |
| source=str(dir_path), | |
| spec=str(cand), | |
| mode=ResolutionMode.LEGACY, | |
| ) | |
| cand_fx = dir_path / f"{cmd_name}.fx" | |
| if cand_fx.is_file(): | |
| return ResolvedCommand( | |
| kind=CommandKind.LEGACY_FX_HOST_TOOL, | |
| vendor=vd.name, | |
| script=str(cand_fx), | |
| exec_path=str(cand_fx), | |
| inject_prebuilt_paths=1, | |
| category=Category.OTHER, | |
| contrib=1 if "contrib" in subdir else 0, | |
| source=str(cand_fx), | |
| spec=str(cand_fx), | |
| mode=ResolutionMode.LEGACY_FX, | |
| ) | |
| # If --vendor was specified, do not search core tools | |
| if vendor_name: | |
| return ResolvedCommand.not_found() | |
| # Level 3: Core registered tools | |
| core_reg = fuchsia_dir / "tools" / "devshell" / "fx_host_tools.json" | |
| core_entries = load_registry_file(core_reg, source_label="core") | |
| if cmd_name in core_entries: | |
| return ResolvedCommand.from_registry_entry( | |
| fuchsia_dir, core_entries[cmd_name], vendor="", source=core_reg | |
| ) | |
| # Level 4: Core legacy scripts / .fx metadata | |
| for subdir in ("tools/devshell", "tools/devshell/contrib"): | |
| dir_path = fuchsia_dir / subdir | |
| if not dir_path.is_dir(): | |
| continue | |
| cand = dir_path / cmd_name | |
| if cand.is_file() and os.access(cand, os.X_OK): | |
| return ResolvedCommand( | |
| kind=CommandKind.LEGACY_SCRIPT, | |
| script=str(cand), | |
| exec_path=str(cand), | |
| inject_prebuilt_paths=1, | |
| category=Category.OTHER, | |
| contrib=1 if "contrib" in subdir else 0, | |
| source=str(dir_path), | |
| spec=str(cand), | |
| mode=ResolutionMode.LEGACY, | |
| ) | |
| cand_fx = dir_path / f"{cmd_name}.fx" | |
| if cand_fx.is_file(): | |
| return ResolvedCommand( | |
| kind=CommandKind.LEGACY_FX_HOST_TOOL, | |
| script=str(cand_fx), | |
| exec_path=str(cand_fx), | |
| inject_prebuilt_paths=1, | |
| category=Category.OTHER, | |
| contrib=1 if "contrib" in subdir else 0, | |
| source=str(cand_fx), | |
| spec=str(cand_fx), | |
| mode=ResolutionMode.LEGACY_FX, | |
| ) | |
| # Level 5: Legacy host tools fallback in build_dir/host-tools | |
| if build_dir is not None: | |
| cand_host_tool = build_dir / "host-tools" / cmd_name | |
| if cand_host_tool.is_file() and os.access(cand_host_tool, os.X_OK): | |
| return ResolvedCommand( | |
| kind=CommandKind.LEGACY_FX_HOST_TOOL, | |
| script=str(cand_host_tool), | |
| exec_path=str(cand_host_tool), | |
| inject_prebuilt_paths=1, | |
| category=Category.OTHER, | |
| source=str(cand_host_tool), | |
| spec=str(cand_host_tool), | |
| mode=ResolutionMode.LEGACY_HOST_TOOL, | |
| ) | |
| return ResolvedCommand.not_found() | |
| def compute_isolated_path( | |
| inject_prebuilt_paths: int, | |
| current_path: str, | |
| prebuilt_all_paths: str, | |
| saved_prebuilt_all_paths: str, | |
| ) -> str: | |
| path_segments = [p for p in current_path.split(":") if p] | |
| # Always strip any runtime_dir bin dirs from parent Bazel invocations | |
| cleaned_segments: list[str] = [] | |
| for seg in path_segments: | |
| norm = seg.rstrip("/") | |
| if norm.endswith(".runtime_dir/.bin") or norm.endswith( | |
| ".runtime_dir/bin" | |
| ): | |
| continue | |
| cleaned_segments.append(seg) | |
| prebuilt_segments = [p for p in prebuilt_all_paths.split(":") if p] | |
| saved_segments = [p for p in saved_prebuilt_all_paths.split(":") if p] | |
| prebuilt_set = set(prebuilt_segments) | set(saved_segments) | |
| stripped_segments = [ | |
| seg for seg in cleaned_segments if seg not in prebuilt_set | |
| ] | |
| if inject_prebuilt_paths == 0: | |
| return ":".join(stripped_segments) | |
| # inject_prebuilt_paths == 1: prepend prebuilts before ambient PATH segments | |
| restore_candidates = saved_segments if saved_segments else prebuilt_segments | |
| result: list[str] = [] | |
| for seg in restore_candidates + stripped_segments: | |
| if seg not in result: | |
| result.append(seg) | |
| return ":".join(result) | |
| def _cmd_resolve(args: argparse.Namespace, fuchsia_dir: Path) -> int: | |
| build_dir = Path(args.build_dir) if args.build_dir else None | |
| res = resolve_command( | |
| fuchsia_dir, | |
| args.command, | |
| build_dir=build_dir, | |
| vendor_name=args.vendor, | |
| ) | |
| if res.kind == CommandKind.NOT_FOUND: | |
| print("KIND=not_found") | |
| return 1 | |
| print(f"KIND={res.kind}") | |
| print(f"VENDOR={res.vendor}") | |
| print(f"TARGET={res.target}") | |
| print(f"SCRIPT={res.script}") | |
| print(f"EXEC_PATH={res.exec_path}") | |
| print(f"INJECT_PREBUILT_PATHS={res.inject_prebuilt_paths}") | |
| if args.path: | |
| computed_path = compute_isolated_path( | |
| inject_prebuilt_paths=res.inject_prebuilt_paths, | |
| current_path=args.path, | |
| prebuilt_all_paths=args.prebuilt_all_paths or "", | |
| saved_prebuilt_all_paths=args.saved_prebuilt_all_paths or "", | |
| ) | |
| print(f"COMPUTED_PATH={computed_path}") | |
| print(f"CATEGORY={res.category}") | |
| print(f"SUMMARY={res.summary}") | |
| print(f"CONTRIB={res.contrib}") | |
| print(f"DEPRECATED={res.deprecated}") | |
| print(f"SOURCE={res.source}") | |
| print(f"SPEC={res.spec}") | |
| print(f"MODE={res.mode}") | |
| return 0 | |
| def _cmd_compute_path(args: argparse.Namespace) -> int: | |
| new_path = compute_isolated_path( | |
| inject_prebuilt_paths=args.inject_prebuilt_paths, | |
| current_path=args.path, | |
| prebuilt_all_paths=args.prebuilt_all_paths or "", | |
| saved_prebuilt_all_paths=args.saved_prebuilt_all_paths or "", | |
| ) | |
| print(new_path) | |
| return 0 | |
| def _cmd_help_command(args: argparse.Namespace, fuchsia_dir: Path) -> int: | |
| build_dir = Path(args.build_dir) if args.build_dir else None | |
| cmd = args.command | |
| res = resolve_command( | |
| fuchsia_dir, | |
| cmd, | |
| build_dir=build_dir, | |
| vendor_name=args.vendor, | |
| ) | |
| if res.kind not in ( | |
| CommandKind.REGISTERED_SCRIPT, | |
| CommandKind.REGISTERED_TARGET, | |
| ): | |
| return 1 | |
| env = os.environ.copy() | |
| env["FX_INJECT_PREBUILT_PATHS"] = str(res.inject_prebuilt_paths) | |
| env["PATH"] = compute_isolated_path( | |
| inject_prebuilt_paths=res.inject_prebuilt_paths, | |
| current_path=env.get("PATH", ""), | |
| prebuilt_all_paths=env.get("PREBUILT_ALL_PATHS", ""), | |
| saved_prebuilt_all_paths=env.get("_FX_SAVED_PREBUILT_ALL_PATHS", ""), | |
| ) | |
| if res.kind == CommandKind.REGISTERED_SCRIPT: | |
| script_path = Path(res.exec_path) | |
| if script_path.is_file() and ( | |
| script_path.suffix == ".py" or os.access(script_path, os.X_OK) | |
| ): | |
| cmd_argv = ( | |
| [sys.executable, str(script_path), "--help"] | |
| if script_path.suffix == ".py" | |
| else [str(script_path), "--help"] | |
| ) | |
| proc = subprocess.run( | |
| cmd_argv, | |
| env=env, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| if proc.stdout: | |
| sys.stdout.write(proc.stdout) | |
| if proc.stderr: | |
| sys.stderr.write(proc.stderr) | |
| return proc.returncode | |
| print(f"{res.summary}\n") | |
| print(f"Category: {res.category}") | |
| print(f"Registry: {res.mode} {res.spec}") | |
| return 0 | |
| # Registered target mode | |
| if build_dir is not None and build_dir.is_dir(): | |
| candidates = [ | |
| fuchsia_dir / "out" / "_fx_host_tools" / cmd, | |
| build_dir.parent / "_fx_host_tools" / cmd, | |
| build_dir / "host-tools" / cmd, | |
| ] | |
| for cand in candidates: | |
| if cand.is_file() and os.access(cand, os.X_OK): | |
| proc = subprocess.run( | |
| [str(cand), "--help"], | |
| env=env, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| if proc.stdout: | |
| sys.stdout.write(proc.stdout) | |
| if proc.stderr: | |
| sys.stderr.write(proc.stderr) | |
| return proc.returncode | |
| print(f"fx {cmd}: {res.summary or f'registered host tool ({res.spec})'}") | |
| if res.category: | |
| print(f" Category: {res.category}") | |
| print(f" Target: {res.spec}\n") | |
| print(f"Run fx {cmd} --help to build and view full flag documentation.") | |
| return 0 | |
| def _cmd_ensure_target(args: argparse.Namespace, fuchsia_dir: Path) -> int: | |
| # In CL 1, target mode execution is a stub. Full build & staging lands in CL 3. | |
| res = resolve_command( | |
| fuchsia_dir, args.command, vendor_name=getattr(args, "vendor", None) | |
| ) | |
| spec = res.spec or f"//{args.command}:{args.command}" | |
| print( | |
| f"ERROR: Registered target host tool '{args.command}' ({spec}) requires fx_host_tool support.", | |
| file=sys.stderr, | |
| ) | |
| return 1 | |
| def _build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| prog="fx_cli.locator", | |
| description="Subcommand locator and environment preparation for fx.", | |
| ) | |
| parser.add_argument( | |
| "--fuchsia-dir", | |
| help="Path to Fuchsia source checkout root.", | |
| ) | |
| subparsers = parser.add_subparsers(dest="subcommand") | |
| resolve_parser = subparsers.add_parser( | |
| "resolve", | |
| help="Resolve subcommand using 4-level vendor-first resolution.", | |
| ) | |
| resolve_parser.add_argument("command", help="Command name.") | |
| resolve_parser.add_argument( | |
| "--vendor", | |
| help="Vendor name if restricted to a specific vendor.", | |
| ) | |
| resolve_parser.add_argument( | |
| "--build-dir", | |
| help="Fuchsia build directory path.", | |
| ) | |
| resolve_parser.add_argument( | |
| "--path", | |
| help="Current PATH value to compute isolated or injected PATH.", | |
| ) | |
| resolve_parser.add_argument( | |
| "--prebuilt-all-paths", | |
| default="", | |
| help="Colon-separated PREBUILT_ALL_PATHS.", | |
| ) | |
| resolve_parser.add_argument( | |
| "--saved-prebuilt-all-paths", | |
| default="", | |
| help="Colon-separated _FX_SAVED_PREBUILT_ALL_PATHS.", | |
| ) | |
| path_parser = subparsers.add_parser( | |
| "compute-path", | |
| help="Compute isolated or injected PATH.", | |
| ) | |
| path_parser.add_argument( | |
| "--inject-prebuilt-paths", | |
| type=int, | |
| choices=[0, 1], | |
| required=True, | |
| help="0 for isolated PATH, 1 to restore PREBUILT_ALL_PATHS.", | |
| ) | |
| path_parser.add_argument( | |
| "--path", | |
| required=True, | |
| help="Current PATH value.", | |
| ) | |
| path_parser.add_argument( | |
| "--prebuilt-all-paths", | |
| default="", | |
| help="Colon-separated PREBUILT_ALL_PATHS.", | |
| ) | |
| path_parser.add_argument( | |
| "--saved-prebuilt-all-paths", | |
| default="", | |
| help="Colon-separated _FX_SAVED_PREBUILT_ALL_PATHS.", | |
| ) | |
| help_parser = subparsers.add_parser( | |
| "help-command", | |
| help="Print help metadata for registered command.", | |
| ) | |
| help_parser.add_argument("command", help="Command name.") | |
| help_parser.add_argument( | |
| "--vendor", | |
| help="Vendor name if restricted to a specific vendor.", | |
| ) | |
| help_parser.add_argument( | |
| "--build-dir", | |
| help="Fuchsia build directory path.", | |
| ) | |
| ensure_parser = subparsers.add_parser( | |
| "ensure-target", | |
| help="Ensure registered target host tool is built and staged.", | |
| ) | |
| ensure_parser.add_argument("command", help="Command name.") | |
| ensure_parser.add_argument( | |
| "--build-dir", | |
| help="Fuchsia build directory path.", | |
| ) | |
| return parser | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = _build_parser() | |
| args = parser.parse_args(argv) | |
| fuchsia_dir = ( | |
| Path(args.fuchsia_dir).resolve() | |
| if args.fuchsia_dir | |
| else resolve_fuchsia_dir() | |
| ) | |
| try: | |
| if args.subcommand == "resolve": | |
| return _cmd_resolve(args, fuchsia_dir) | |
| if args.subcommand == "compute-path": | |
| return _cmd_compute_path(args) | |
| if args.subcommand == "help-command": | |
| return _cmd_help_command(args, fuchsia_dir) | |
| if args.subcommand == "ensure-target": | |
| return _cmd_ensure_target(args, fuchsia_dir) | |
| except RegistryError as exc: | |
| print(f"ERROR: {exc}", file=sys.stderr) | |
| return 2 | |
| except OSError as exc: | |
| print(f"ERROR: {exc}", file=sys.stderr) | |
| return 1 | |
| parser.print_help() | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |