Skip to content

[docs] CLI dispatcher rewrite: document 5-rule auto-discovery routing after PraisonAI #1577 (supersedes #282 dispatcher section) #286

@MervinPraison

Description

@MervinPraison

Context

PraisonAI PR MervinPraison/PraisonAI#1577fix(wrapper): restore #1561 auto-discovery dispatcher in __main__.py (closes #1576) — was merged to main on 2026-04-28 (head SHA 8d373843dc062f358ff5be7722a8cadbe71ce105).

This PR replaces the entire CLI dispatcher implementation that issues #282 and #276 were drafted against. The previous heuristic-based routing (_is_legacy_invocation()) is gone; the routing model is now Typer-first auto-discovery with five explicit precedence rules. Any unmerged doc work targeting the old 3-tier routing diagram in #282 is now factually wrong and must be updated against this PR's source-of-truth.

The dispatcher is the single user-facing entry point for everything typed as praisonai ... — its routing rules are non-obvious (--help and --version go to different paths, a YAML filename routes by command-set membership rather than file existence, etc.) and have never been documented in MervinPraison/PraisonAIDocs. The 26 unit tests added in #1577 pin the contract precisely; treat each test name as a documented behavior.

Relationship to existing issues


SDK Truth (read these files before writing the doc)

Per AGENTS.md §1.2 SDK-First cycle — read source, understand, then document. Do not copy from #282/#276 prose; those describe the old model.

Module-path note: In MervinPraison/PraisonAIDocs (synced via update_repos.sh), wrapper sources land at repo-root praisonai/, not src/praisonai/. Verify paths against the synced tree before quoting them in docs.

File (PraisonAI repo path) PraisonAIDocs mirror Why read it
src/praisonai/praisonai/__main__.py praisonai/__main__.py The dispatcher itself — main(), _get_typer_commands(), _find_first_command(), _run_typer(), _run_legacy(), _typer_commands_cache, _typer_commands_lock.
src/praisonai/praisonai/cli/app.py praisonai/cli/app.py register_commands() — the registration is now wrapped in the same lock the discoverer uses, and _commands_registered = True is flipped only after the last add_typer() succeeds (no half-registered command tree).
src/praisonai/tests/unit/cli/test_main_dispatcher.py (tests live in PraisonAI repo only) 26 tests across 6 classes — each test name is a documented behavior.

Key facts to extract from the source

  1. Routing precedence (from main() in __main__.py, in order):

    1. --version / -V → print version and returndoes not import praisonai.cli.* (proven by TestVersionShortCircuit::test_version_does_not_import_typer_or_legacy, which evicts praisonai.cli.* from sys.modules and asserts they stay absent).
    2. --help / -h → Typer (so help auto-discovers all registered subcommands).
    3. No argv → Typer (interactive TUI).
    4. First non-flag positional ∈ _get_typer_commands() → Typer.
    5. Otherwise (free-text prompts, .yaml/.yml paths, deprecated flags) → legacy PraisonAI().main().
  2. First-positional discovery (_find_first_command(argv)):

    • Skips leading global flags like --verbose, --debug, --json.
    • Treats --output-format and -o as value flags — also skips the value that follows them.
    • Returns None when argv has only flags (rule 5 above falls through to "Typer for global-flag handling").
    • Free-text prompts (token containing a space) and .yaml paths are returned as-is — the routing decision happens in main() based on command-set membership, not by inspecting the filesystem.
  3. Auto-discovery cache (_get_typer_commands()):

    • Lazily loads app + calls register_commands(), then introspects via click.Context(typer.main.get_command(app)).list_commands(ctx).
    • Result cached in module-global _typer_commands_cache under _typer_commands_lock.
    • Cache is NOT poisoned on failure: a discovery error returns set() for that call but leaves _typer_commands_cache = None so the next caller retries (verified by TestGetTyperCommandsCache::test_failure_does_not_poison_cache).
    • Concurrent callers get the same cached object — 8-thread stress test in TestGetTyperCommandsCache::test_concurrent_callers_get_same_result.
  4. Failure modes (fail-loud invariant):

    • register_commands() exceptions inside _run_typer propagate — they are NOT wrapped in try/except. A missing optional dep surfaces as the underlying ImportError, not as Typer's empty-app behavior. Pinned by TestTyperRegistrationFailureFailsLoud.
    • _run_typer and _run_legacy both restore sys.argv in finally: even on SystemExit — pinned by TestRunTyperArgvRestoration and TestRunLegacyArgvRestoration (both use argv[0] = "/usr/local/bin/some-launcher" so the assertion has discriminating power against a missing finally).
  5. Why this matters to users (extensibility):

    • Adding a Typer subcommand → automatic routing. No manual command list, no opt-in. As soon as the new command is registered in praisonai.cli.app, typing praisonai <new-command> routes to Typer.
    • No filesystem dependency for YAML routing. A typo'd YAML path no longer silently falls into legacy because the file doesn't exist — it falls into legacy because it isn't a registered command. Misroutes that used to depend on os.path.isfile() are gone.

Task — NEW page: docs/features/cli-dispatcher.mdx

Folder placement (AGENTS.md §1.8 — non-negotiable):

  • Place at docs/features/cli-dispatcher.mdx. NEVER docs/concepts/.
  • Add an entry to docs.json under the Features group (not Concepts, not the auto-managed docs/js/ or docs/rust/ groups).
  • Validate docs.json is still valid JSON after editing.

Required frontmatter

---
title: "CLI Dispatcher"
sidebarTitle: "CLI Dispatcher"
description: "How praisonai routes your command — version, Typer subcommand, or legacy prompt/YAML"
icon: "terminal"
---

Required structure (per AGENTS.md §2)

  1. One-sentence intro — agent-centric, beginner-friendly. Example tone: "praisonai picks one of five paths based on what you type — and adding a new subcommand means it Just Works."
  2. Hero Mermaid diagram (see exact spec below).
  3. ## Quick Start with <Steps> — show the five routing paths as five Steps (praisonai --version, praisonai, praisonai --help, praisonai chat "...", praisonai "free-text prompt").
  4. ## How It WorkssequenceDiagram showing User → main() → [version|help|empty|known-cmd|fallback] → [Typer|Legacy|exit].
  5. ## Routing Rules — the precedence table below, copied verbatim.
  6. ## Auto-Discovery — explain why adding a new Typer subcommand "just works" (one short paragraph + 4-line example of registering a command and seeing it route).
  7. ## Common Patterns — 3 examples: bare prompt, .yaml file, subcommand with global flags (e.g. praisonai --verbose chat "hi" — show that --verbose is skipped when finding the first positional).
  8. ## Best Practices with <AccordionGroup>:
    • "Why --version is fast" (no praisonai.cli.* imports → still works with broken optional deps).
    • "Adding a new subcommand" (register in cli/app.py, no dispatcher changes needed).
    • "Free-text prompts vs. typo'd command names" (a typo routes to legacy, not to a Typer "command not found" error — explain why).
    • "Failure visibility" (registration errors propagate; the dispatcher does NOT swallow them).
  9. ## Related with <CardGroup cols={2}> — link to /docs/cli/cli, /docs/cli/cli-reference, /docs/features/gateway, /docs/cli/version.

Hero diagram (use these exact colors — AGENTS.md §3.1)

graph TB
    Start[📋 praisonai argv] --> V{🔍 --version<br/>or -V?}
    V -->|Yes| Print[⚡ Print version<br/>no cli.* imports]
    V -->|No| H{🔍 --help<br/>or -h?}
    H -->|Yes| Typer1[🧰 Typer help]
    H -->|No| E{🔍 argv empty?}
    E -->|Yes| Typer2[🧰 Typer TUI]
    E -->|No| F[🔎 first non-flag positional]
    F --> K{🧠 in registered<br/>Typer commands?}
    K -->|Yes| Typer3[🧰 Typer subcommand]
    K -->|No| Legacy[🤖 Legacy<br/>PraisonAI().main]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef route fill:#10B981,stroke:#7C90A0,color:#fff
    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff

    class Start input
    class V,H,E,K check
    class F input
    class Print,Typer1,Typer2,Typer3 route
    class Legacy agent
Loading

Routing rules table (copy verbatim — derived from __main__.py:main() post-#1577)

# What you type Route Notes
1 praisonai --version / praisonai -V Version short-circuit Prints version and returns. Does not import praisonai.cli.* — stays fast even with broken optional deps. Pinned by TestVersionShortCircuit.
2 praisonai --help / praisonai -h Typer Typer's auto-generated help lists every registered subcommand (auto-discovered, no manual list).
3 praisonai (no argv) Typer Drops into Typer's interactive TUI.
4 praisonai --verbose / praisonai -o json (only flags) Typer _find_first_command returns None → Typer handles global-flag-only cases.
5 praisonai chat ... (first positional ∈ registered commands) Typer Auto-discovered via Click introspection of app. Adding a new subcommand to cli/app.py makes it routable here with zero dispatcher changes.
6 praisonai "Build a weather agent" (free-text — token contains a space) Legacy PraisonAI().main() Free-text prompts always fall through to legacy.
7 praisonai agents.yaml (filename, not a registered command) Legacy PraisonAI().main() Routing decision is by command-set membership, NOT by os.path.isfile(). A typo'd YAML path also routes to legacy and surfaces there.
8 praisonai totally-unknown (unknown positional) Legacy PraisonAI().main() Same as row 7 — anything not in the discovered command set falls through.

Auto-discovery callout

Add a <Note> block:

Adding a new subcommand? Register it in praisonai/cli/app.py (e.g. app.add_typer(my_app, name="mycmd")) and the dispatcher picks it up automatically — praisonai mycmd ... routes to Typer with no changes to __main__.py. The command set is discovered once via click.Context.list_commands() and cached behind a thread-safe lock.

Failure-modes callout

Add a <Warning> block:

Registration errors fail loud. If register_commands() raises (e.g. an ImportError from a missing optional dep), the exception propagates from praisonai ... — you see the real error, not Typer's "no command" page. This is intentional and pinned by tests.


Acceptance criteria

  • New file docs/features/cli-dispatcher.mdx exists, follows the structure above, passes the AGENTS.md §9 quality checklist (frontmatter, hero diagram, Steps, AccordionGroup, CardGroup all present).
  • Hero diagram uses the AGENTS.md §3.1 color palette exactly (#6366F1 input, #F59E0B check, #10B981 route, #8B0000 legacy/agent, white text, #7C90A0 strokes).
  • Routing rules table is the 8-row table above, verbatim.
  • At least one <Note> covers auto-discovery and one <Warning> covers fail-loud registration.
  • docs.json updated to add the new page under the Features group; docs.json is still valid JSON.
  • No edits to docs/concepts/. No edits to docs/js/ or docs/rust/.
  • No invented APIs — every claim traces to praisonai/__main__.py or praisonai/cli/app.py post-#1577 (head SHA 8d373843dc062f358ff5be7722a8cadbe71ce105).
  • Document does not reference the old _is_legacy_invocation() heuristic — it was removed in #1577.

Out of scope (do NOT do in this PR)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeTrigger Claude Code analysisdocumentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions