"""CLI interface for olink.
Every action is a subcommand of the single ``olink`` Typer root, so each carries
its own ``--help`` and ``--json`` contract. Under ``--json`` stdout holds exactly
one JSON document (errors included), so agents can parse it without scraping
prose. Secondary components (``olink interactive`` for the TUI) are
lazily-imported subcommands rather than separate ``olink-<name>`` console scripts
(see https://hasansezertasan.github.io/copier-pyproject/context/components/#secondary-components-are-subcommands-of-one-console-root-not-separate-scripts).
"""
import contextlib
import json
import logging
import platform
from pathlib import Path
from typing import TYPE_CHECKING, Annotated
import typer
from typer._click.exceptions import UsageError # noqa: PLC2701 - Typer uses its bundled Click
from typer.core import TyperGroup
from olink import __version__
from olink.core.catalog import get_target, list_available_targets, list_targets
from olink.core.exceptions import (
InvalidDirectoryError,
NoRemoteError,
NotGitRepoError,
OlinkError,
ProjectMetadataError,
UnknownPlatformError,
UnknownTargetError,
UnsupportedFeatureError,
)
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from typer._click.core import Context
__all__ = ["main"]
logger = logging.getLogger(__name__)
_TUI_OPTIONAL_DEPS = frozenset({"olink.tui", "textual", "pyperclip"})
# Distinct exit codes let a caller branch on the failure without parsing text.
# 1 is the generic OlinkError and 2 is Typer's own usage error, so start at 3.
EXIT_CODES: dict[type[OlinkError], int] = {
UnknownTargetError: 3,
InvalidDirectoryError: 4,
NotGitRepoError: 5,
NoRemoteError: 6,
ProjectMetadataError: 7,
UnsupportedFeatureError: 8,
UnknownPlatformError: 9,
}
class _JsonGroup(TyperGroup):
"""Preserve machine-readable errors before command callbacks are entered."""
def invoke(self, ctx: Context) -> object:
arguments = ctx.args
if "--" in arguments:
arguments = arguments[: arguments.index("--")]
as_json = "--json" in arguments
try:
return super().invoke(ctx)
except UsageError as error:
if not as_json:
raise
_emit({"error": {"type": type(error).__name__, "message": error.format_message()}})
raise typer.Exit(error.exit_code) from error
app = typer.Typer(
name="olink",
help="Open external URLs related to your project.",
no_args_is_help=True,
cls=_JsonGroup,
)
_Directory = Annotated[
(
str | None,
typer.Option(
"--directory", "-d", help="Project directory (defaults to current directory)."
),
)
]
_Json = Annotated[
(bool, typer.Option("--json", help="Print a single JSON document on stdout instead of text."))
]
_Target = Annotated[
(
str,
typer.Argument(help="Target name (e.g. origin, issues, pypi, snyk:npm). See `olink list`."),
)
]
def _emit(payload: Mapping[str, object]) -> None:
typer.echo(json.dumps(payload, indent=2))
@contextlib.contextmanager
def _reporting(*, as_json: bool) -> Generator[None]:
"""Turn an OlinkError into a typed exit code, as JSON or text."""
try:
yield
except OlinkError as e:
if as_json:
_emit({"error": {"type": type(e).__name__, "message": str(e)}})
else:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(EXIT_CODES.get(type(e), 1)) from e
def _resolve_directory(directory: str | None) -> str:
cwd = directory or str(Path.cwd())
path = Path(cwd)
if not path.exists():
msg = f"Directory does not exist: {cwd}"
raise InvalidDirectoryError(msg)
if not path.is_dir():
msg = f"Not a directory: {cwd}"
raise InvalidDirectoryError(msg)
return cwd
def _version_callback(value: bool) -> None:
if value:
typer.echo(f"olink {__version__}")
raise typer.Exit(0)
@app.callback()
def root(
_version: Annotated[
bool,
typer.Option(
"--version",
"-V",
help="Show olink version and exit.",
callback=_version_callback,
is_eager=True,
),
] = False,
) -> None:
"""Open external URLs related to your project."""
@app.command(name="open")
def open_target(target: _Target, directory: _Directory = None, as_json: _Json = False) -> None:
"""Open a target's URL in the browser."""
with _reporting(as_json=as_json):
resolved_url = get_target(target).get_url(_resolve_directory(directory))
opened = typer.launch(resolved_url) == 0
if as_json:
_emit({"target": target, "url": resolved_url, "opened": opened})
else:
typer.echo(f"Opening: {resolved_url}")
@app.command()
def url(target: _Target, directory: _Directory = None, as_json: _Json = False) -> None:
"""Print a target's URL without opening it."""
with _reporting(as_json=as_json):
resolved = get_target(target).get_url(_resolve_directory(directory))
if as_json:
_emit({"target": target, "url": resolved})
else:
typer.echo(resolved)
@app.command(name="list")
def list_(
all_targets: Annotated[
bool,
typer.Option("--all", "-a", help="List every target, not only those for this project."),
] = False,
directory: _Directory = None,
as_json: _Json = False,
) -> None:
"""List targets available for the current project."""
rows: list[dict[str, str | None]]
if all_targets:
rows = [
{"name": name, "description": description, "ecosystem": None}
for name, description in list_targets()
]
else:
with _reporting(as_json=as_json):
cwd = _resolve_directory(directory)
rows = [
{"name": name, "description": description, "ecosystem": ecosystem}
for name, description, _, ecosystem in list_available_targets(cwd)
]
if as_json:
_emit({"scope": "all" if all_targets else "available", "count": len(rows), "targets": rows})
return
if not rows:
typer.echo("No targets available for this project.")
return
typer.echo("All targets:\n" if all_targets else "Available targets for this project:\n")
for row in rows:
typer.echo(f" {row['name']:16} - {row['description']}")
if not all_targets:
typer.echo(f"\n({len(rows)} targets available)")
@app.command()
def interactive(directory: _Directory = None) -> None:
"""Launch the interactive TUI (requires the tui extra)."""
with _reporting(as_json=False):
cwd = _resolve_directory(directory)
try:
from olink.tui import launch_tui # pylint: disable=import-outside-toplevel
except ImportError as e:
if e.name not in _TUI_OPTIONAL_DEPS:
raise
typer.echo(
"Error: TUI requires extra dependencies. Install with: "
"pip install olink[tui] (or: uv tool install 'olink[tui]')",
err=True,
)
raise typer.Exit(1) from None
with contextlib.suppress(KeyboardInterrupt, SystemExit):
launch_tui(cwd)
@app.command()
def version(_directory: _Directory = None, as_json: _Json = False) -> None:
"""Show the olink version."""
if as_json:
_emit({"version": __version__})
else:
typer.echo(f"olink {__version__}")
@app.command()
def info(_directory: _Directory = None, as_json: _Json = False) -> None:
"""Show version, Python and platform details (useful in bug reports)."""
payload = {
"version": __version__,
"python": platform.python_version(),
"platform": platform.platform(),
}
if as_json:
_emit(payload)
return
typer.echo(f"olink Version: {payload['version']}")
typer.echo(f"Python Version: {payload['python']}")
typer.echo(f"Platform: {payload['platform']}")
[docs]
def main() -> None:
"""Entry point for the CLI."""
app()