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:
- 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.
- 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.
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 aspraisonai 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 78app.add_typer(...)calls (each building a Typer/Click command tree by introspecting the module's command functions):It is then invoked unconditionally at module import:
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: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 asfrom .app import state(used incli/main.py:5217to read an output-mode flag) triggers the same full fan-out via the module-level call atapp.py:685.The command modules themselves are disciplined (heavy deps such as
fastapi/uvicornare imported lazily inside functions — verified incommands/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
register_commands()atapp.py:685is redundant — every legitimate caller already invokes it (__main__._get_typer_commands:41,__main__._run_typer:107, and six sites incli/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 rawImportErrorat 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
run,chat,serve,doctor,gateway,bot,kanban,schedule,deploy, …) remains available and behaves identically.Proposed approach
Non-breaking, in order of effort:
app.py:685. Add an explicitregister_commands()at the one dispatch site that importsappwithout registering (cli/main.py:1532, just beforetyper_app()); symbol-only importers (from .app import state) then no longer trigger the fan-out, and the guarded callers control failure handling as designed._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
Layer placement
cli/app.py,__main__.py)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.register_commands();cli/main.py:1532and:5217are the only bare-symbol importers.Keep unchanged
_commands_registeredguard and lock, and the__main__retry/anti-poison logic.cli/main.py.