Architecture¶
netbox-cli is organized around a shared API client and OpenAPI schema index that power both the CLI (Typer) and the TUI (Textual) from the same data layer.
In addition to the bundled OpenAPI schema, the TUI runtime can augment the schema index by discovering live plugin REST resources exposed under /api/plugins/. This lets plugin-backed resources appear in the TUI automatically when a plugin implements a full REST API.
The TUI theme system is part of the architecture, not decoration: every Textual widget and subcomponent must derive its runtime styling from the active theme catalog, with no hardcoded colors or stray Textual defaults outside netbox_cli/themes/*.json.
Module map¶
netbox_cli/
├── cli.py Typer app — all commands, dynamic registration, profile dispatch
├── api.py Async aiohttp HTTP client (ApiResponse, NetBoxApiClient)
├── config.py Profile storage, env overrides, token normalization
├── schema.py OpenAPI schema loading and indexing (SchemaIndex)
├── services.py Request resolution and action mapping (run_dynamic_command)
├── demo_auth.py Playwright automation for demo.netbox.dev token retrieval
├── docgen_capture.py CLI output capture and Markdown generation
├── theme_registry.py Theme discovery, validation, and catalog management
├── ui_common.tcss Shared visual design layer for both Textual apps
├── trace_ascii.py ASCII cable trace renderer
├── tui.py Thin wrapper — re-exports run_tui from ui.app
├── dev_tui.py Thin wrapper — re-exports run_dev_tui from ui.dev_app
└── ui/
├── app.py NetBoxTuiApp — main Textual application
├── dev_app.py NetBoxDevTuiApp — request workbench application
├── chrome.py Shared theme / clock / logo / connection chrome helpers
├── formatting.py Response parsing, humanization, semantic cell rendering
├── navigation.py Navigation tree building from SchemaIndex
├── plugin_discovery.py Runtime /api/plugins/ discovery for plugin REST resources
├── panels.py ObjectAttributesPanel — detail view with cable trace
├── widgets.py Shared composition primitives (buttons, panel header/body)
├── state.py Main TUI state persistence
└── dev_state.py Dev TUI state persistence
Data flow: CLI¶
nbx dcim devices list
│
▼
root_callback() ensure default profile config is loaded
│
▼
_register_openapi_subcommands() (runs at import time)
reads SchemaIndex → builds Typer sub-apps for every group/resource/action
│
▼
_command() [generated] Typer command for "list" on dcim/devices
│
▼
_execute_dynamic_action()
│
▼
run_dynamic_command() services.py — resolves path, calls client.request()
│
▼
NetBoxApiClient.request() api.py — async aiohttp GET with Bearer token
│
▼
_print_response() Rich table or raw JSON/YAML
Data flow: TUI¶
nbx tui
│
▼
tui_command()
│
▼
run_tui(client, index, theme)
│
▼
NetBoxTuiApp.run() Textual event loop
│
┌───┴────────────────────────────────┐
│ │
▼ ▼
on_tree_node_selected() on_key() / bindings
│
▼
_load_resource_list() @work(thread=True)
│
▼
client.request("GET", list_path)
│
▼
parse_response_rows() formatting.py
│
▼
DataTable (Results tab)
│
▼ (row selected)
_load_object_details() @work(thread=False)
│
├── client.request("GET", detail_path)
└── _load_trace_for_object() (dcim/interfaces only)
│
▼
render_cable_trace_ascii() trace_ascii.py
│
▼
panel.set_trace() ObjectAttributesPanel
UI Composition Pattern¶
The TUI follows a React-style composition model for Textual widgets:
- small reusable widgets act like component primitives
- constructor arguments act like props
- larger views assemble those primitives in
compose() - composition is preferred over inheritance for layout reuse
Examples in the current codebase:
NbxButtonstandardizes size and theme props such astoneNbxPanelHeaderandNbxPanelBodydefine reusable panel structure with prop-like theme inputsObjectAttributesPanelcomposes those primitives instead of inheriting layout from a base panel class
Contributor guideline: when adding new UI, first ask "can this be expressed as nested reusable widgets?" before introducing a new base class.
Profile system¶
Profiles are named configs stored in a single JSON file. Two profiles are currently defined: default and demo.
# config.py
DEFAULT_PROFILE = "default"
DEMO_PROFILE = "demo"
DEMO_BASE_URL = "https://demo.netbox.dev"
In cli.py, the in-process cache is a dict:
Profile loading sequence (for _ensure_profile_config(profile)):
- Check
_RUNTIME_CONFIGS[profile]— return immediately if complete. - Call
load_profile_config(profile)— reads from disk + env vars. - If still incomplete and
profile == DEMO_PROFILE→ call_initialize_demo_profile(). - If still incomplete for default profile → interactive prompt.
- Save result to
_RUNTIME_CONFIGS[profile].
OpenAPI schema indexing¶
schema.py loads reference/openapi/netbox-openapi.json at startup and builds a SchemaIndex:
@dataclass
class SchemaIndex:
def groups() -> list[str] # all app groups
def resources(group) -> list[str] # resources for a group
def operations_for(group, resource) # list of Operation objects
def resource_paths(group, resource) # ResourcePaths (list + detail)
def trace_path(group, resource) -> str|None # /api/.../trace/ if available
Operation holds: group, resource, method, path, operation_id, summary.
ResourcePaths holds: list_path (/api/group/resources/) and detail_path (/api/group/resources/{id}/).
For plugin resources, SchemaIndex also supports runtime augmentation. The TUI can discover plugin list/detail endpoints from the live /api/plugins/ tree and add them into the shared index so they behave like normal resources in navigation, request resolution, and rendering.
API client¶
api.py wraps aiohttp with:
ApiResponsedataclass:status: int,text: str,headers: dictNetBoxApiClient.request(): builds URL, attachesAuthorizationheader, handles v2→v1 token retry on 401/403NetBoxApiClient.probe_connection():GET /api/for health checks
Dynamic command registration¶
_register_openapi_subcommands(target_app, *, client_factory, index_factory) runs at module import time (twice — once for root app, once for demo_app):
for group in index.groups():
group_typer = Typer(...)
target_app.add_typer(group_typer, name=group)
for resource in index.resources(group):
resource_typer = Typer(...)
group_typer.add_typer(resource_typer, name=resource)
for action in _supported_actions(group, resource):
cmd = _build_action_command(group, resource, action, client_factory, index_factory)
resource_typer.command(name=action)(cmd)
The client_factory parameter is what separates the default and demo command trees: _get_client for app, _get_demo_client for demo_app.