Skip to content

All ~78 Typer command groups imported and registered on every CLI invocation before dispatch #1950

@MervinPraison

Description

@MervinPraison

Summary

The wrapper CLI eagerly imports and registers all ~78 command groups on every invocation — even for the most common paths (a bare prompt such as praisonai "hello" or a YAML file such as praisonai agents.yaml) which use none of those sub-commands. This is structural import bloat on the CLI cold-start hot path, not a feature cut.

Current behaviour

register_commands() imports 77 command modules and performs 78 app.add_typer(...) calls (each building a Typer/Click command tree by introspecting the module's command functions):

# src/praisonai/praisonai/cli/app.py:291-371  (imports)
from .commands.config import app as config_app
from .commands.traces import app as traces_app
... 75 more ...
# :374-684  (registration)
app.add_typer(config_app, name="config", ...)
... 77 more add_typer calls ...

It is then invoked unconditionally at module import:

# src/praisonai/praisonai/cli/app.py:685
# Register commands on import
register_commands()

Crucially, the legacy / prompt / YAML fast path also pays the full cost. __main__.main() must decide whether the first token is a known sub-command, and does so by listing every registered command name:

# src/praisonai/praisonai/__main__.py:181
if first_cmd in _get_typer_commands():   # -> imports praisonai.cli.app -> register_commands() -> 78 modules
    _run_typer(argv)
else:
    _run_legacy(argv)                    # prompt / .yaml / deprecated flags

So praisonai "summarise this" imports and registers all 78 command groups purely to conclude that "summarise this" is not a command, then routes to the legacy path that needed none of them. A lightweight symbol import such as from .app import state (used in cli/main.py:5217 to read an output-mode flag) triggers the same full fan-out via the module-level call at app.py:685.

The command modules themselves are disciplined (heavy deps such as fastapi/uvicorn are imported lazily inside functions — verified in commands/dashboard.py, commands/rag.py), so the cost is the 77 module parses + 78 Typer→Click tree builds, paid on every run.

Why it matters

  • CLI cold-start hot path: the most common invocations (prompt, YAML) pay for registering 78 sub-command trees they never use.
  • Per-command cost / registries rebuilt on every invocation: the entire command tree is constructed on each run rather than only the dispatched command.
  • Maintenance / design correctness: the module-level register_commands() at app.py:685 is redundant — every legitimate caller already invokes it (__main__._get_typer_commands :41, __main__._run_typer :107, and six sites in cli/main.py). The import-time call also defeats the carefully guarded retry-on-failure logic in __main__ ("Do NOT poison the cache on failure"), because a registration error then surfaces as a raw ImportError at module import instead of via the graceful fallback that code was written for.

Category

Import bloat (with an Overlap/redundancy element on the duplicate registration call)

Capability preserved

  • Every CLI command (run, chat, serve, doctor, gateway, bot, kanban, schedule, deploy, …) remains available and behaves identically.
  • The "no manual command list — auto-discovery" property is preserved (a generated/declared name→module map still discovers commands automatically).
  • Legacy prompt/YAML/deprecated-flag dispatch is unchanged.

Proposed approach

Non-breaking, in order of effort:

  1. Remove the redundant module-level call at app.py:685. Add an explicit register_commands() at the one dispatch site that imports app without registering (cli/main.py:1532, just before typer_app()); symbol-only importers (from .app import state) then no longer trigger the fan-out, and the guarded callers control failure handling as designed.
  2. Lazy command-group loading (the real cold-start win): register sub-commands via a name→module map and import the matching command module only when that sub-command is actually dispatched (Click/Typer lazy-group pattern). _get_typer_commands() then reads names from the map without importing any command module, so the legacy/prompt fast path stops paying for 78 imports.

Resolution sketch

# Before — app.py (module import side effect)
register_commands()           # imports + registers all 78 groups eagerly

# After — names known without importing the modules
_COMMAND_GROUPS = {
    "config": ".commands.config",
    "run": ".commands.run",
    # ... generated map: name -> module path ...
}

def command_names() -> set[str]:
    return set(_COMMAND_GROUPS)          # no imports

def load_command(name):                  # imported only when dispatched
    import importlib
    return importlib.import_module(_COMMAND_GROUPS[name], __package__).app
# __main__.py — fast path no longer imports command modules
if first_cmd in command_names():
    _run_typer(argv)     # imports only the matched command group
else:
    _run_legacy(argv)

Layer placement

  • Primary layer: wrapper (cli/app.py, __main__.py)
  • Touches core/tools/plugins: none
  • 3-way surface (CLI + YAML + Python): preserved (CLI behaviour identical; YAML/Python entry points unaffected)

Severity

Medium — affects every CLI invocation's cold start, including the most common prompt/YAML paths; fix is non-breaking and incremental.

Validation

  • register_commands() confirmed called at import (app.py:685) and on the legacy/prompt path via __main__.py:181 → _get_typer_commands → register_commands.
  • Command modules confirmed lightweight at import (heavy deps lazy inside functions), so the cost is the eager registration of all groups, not individual module weight.
  • Redundancy confirmed: all dispatch callers already call register_commands(); cli/main.py:1532 and :5217 are the only bare-symbol importers.
  • No behavioural regression: all commands still register before dispatch.

Keep unchanged

  • The auto-discovery philosophy ("no manual command list") — preserved via the generated name map.
  • The thread-safe _commands_registered guard and lock, and the __main__ retry/anti-poison logic.
  • All command implementations, flags, and the legacy argparse fallback in cli/main.py.
  • The lazy-inside-function discipline already present in command modules.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions