Skip to content
GitHub

Architecture

Internal design of the lexigram-cli package.


lexigram-cli is the framework’s command-line interface layer — a single lexigram binary that provides project scaffolding, code generation, database management, dev server control, and runtime inspection. It sits above the core framework and consumes contracts, never the reverse.

flowchart BT
    subgraph UserLayer[Developer Tooling]
        CLI[lexigram-cli<br/>lexigram command]
    end
    subgraph Framework[Lexigram Framework]
        Core[lexigram · DI · Config · Providers]
        Contracts[lexigram-contracts<br/>Protocols · Types · Exceptions]
    end
    subgraph Extensions[Extension Packages]
        Web[lexigram-web]
        SQL[lexigram-sql]
        Auth[lexigram-auth]
        Others[...]
    end

    CLI -->|imports| Core
    CLI -->|imports| Contracts
    Extensions -->|contributors| CLI
    Extensions --> Core
    Extensions --> Contracts

Dependency direction: lexigram-cli imports from lexigram and lexigram-contracts. Extension packages register CLI surfaces via the lexigram.cli.contributors entry point — they contribute generators, commands, health checks, and hooks without lexigram-cli importing them directly.


Every CLI command is a Typer sub-app grouped under the root lexigram Typer application. The root is defined in runtime/main.py and each command group lives in its own module under commands/.

flowchart LR
    subgraph Root[lexigram root]
        CB[callback &#40;global flags&#41;]
    end

    subgraph Groups[Typer Sub-apps]
        INIT[init]
        NEW[new]
        ADD[add]
        DEV[dev]
        RUN[run]
        DB[db]
        EVENTS[events]
        GEN[gen]
        CONFIG[config]
        INSPECT[inspect]
        SHELL[shell]
        CONTRIB[contrib]
        PROJECT[project]
        SYSTEM[system]
    end

    subgraph Meta[Inline Commands]
        VER[version]
        COM[completion]
        LIST[list]
        TESTCMD[test]
        LINTCMD[lint]
    end

    subgraph Contributed[Dynamic — via entry points]
        EXTRA[... additional sub-apps ...]
    end

    Root --> Groups
    Root --> Meta
    Root --> Contributed
GroupFilePurpose
initcommands/init.pyInitialize application.yaml in existing project
newcommands/new.pyScaffold new project or extension package (new project, new package)
addcommands/add.pyAdd a provider (lexigram-sql, lexigram-auth, etc.)
devcommands/dev.pyStart ASGI dev server with hot reload (dev start)
runcommands/run.pySmart runner — auto-detects factory, launches production server
dbcommands/db.pyDatabase management (migrations, shell, backup, restore)
eventscommands/events.pyEvent schema migration and status
gencommands/gen.pyCode generation — assembled from contributors at import time
configcommands/config.pyConfiguration show, validate, diff, env, doctor
inspectcommands/inspect.pyRuntime introspection (providers, routes, container, etc.)
shellcommands/shell.pyInteractive Python REPL with app context
contribcommands/contrib.pyDiscover and inspect installed CLI contributors
projectcommands/project.pyTest, lint, typecheck runner dispatch
systemcommands/system.pySystem info, health check, doctor
metaruntime/main.py (inline)version, completion, list, test, lint

The root callback in runtime/main.py defines shared flags propagated via CLIContext:

FlagDescription
--jsonSwitch all output to JSON mode
--quiet / -qSuppress non-essential output
--debugShow debug output and tracebacks
--no-colorDisable Rich markup
--config / -cExplicit path to application.yaml

The code generation system is a contributor-based pipeline: packages register GeneratorDefinition entries via CliContributorProtocol, the CommandAssembler wires them as Typer subcommands under lexigram gen, and dynamic dispatch resolves a GeneratorAdapter at invocation time.

sequenceDiagram
    actor User
    participant Shell as lexigram gen provider
    participant Gen as gen.py
    participant CA as CommandAssembler
    participant GR as GeneratorRegistry
    participant GA as GeneratorAdapter
    participant Impl as ProviderGenerator
    participant FS as File System

    User->>Gen: lexigram gen provider UserService --fields name:str,email:string
    Gen->>Gen: _ensure_assembled(app)
    Gen->>CA: assemble(app)
    CA->>GR: get("provider")
    CA->>GR: get_adapter("provider")
    GR->>GA: _load_generator_class(definition.generator_path)
    GA->>Impl: ProviderGenerator()
    User->>CA: _command("UserService", fields="name:str,email:string")
    CA->>GA: generate("UserService", ...)
    GA->>Impl: generate("UserService", fields_str="name:str,email:string")
    Impl->>FS: Render Jinja2 template → write file
    FS-->>Impl: File written
    Impl-->>GA: GenerationResult
    GA-->>CA: GeneratorResult
    CA-->>Gen: Print "Created: src/providers/user_service_provider.py"
    Gen-->>User: Success output
  1. RegistrationCliContributorProtocol.get_generators() returns GeneratorDefinition objects with a generator_path (dotted module:ClassName), name, and default output directory.
  2. AssemblyCommandAssembler.assemble() iterates all contributors and creates a Typer command function per generator.
  3. Dispatch — When invoked, the command uses GeneratorRegistry to resolve the definition, then get_adapter() to lazily import and instantiate a GeneratorAdapter wrapping the real generator class.
  4. ExecutionGeneratorAdapter.generate() delegates to the real generator’s .generate() method, which renders Jinja2 templates and writes files.
lexigram.codegen.base.GeneratorBase ← framework base
└── ProviderGenerator ├── generates provider.py from provider.py.jinja2
└── TestGenerator └── generates test files from test_unit.py.jinja2

Both built-in generators use TemplateRenderer with the Jinja2 templates in templates/. External packages contribute their own generators via the contributor system.

# Typical registration in a CliContributor
from lexigram.contracts.cli.types import GeneratorDefinition
generators = [
GeneratorDefinition(
name="provider",
title="Generate Provider",
description="Generate a provider class",
contributor="my_package",
generator_path="my_package.generators:MyGenerator",
default_output_dir="src/providers",
),
]

lexigram-cli integrates with the framework’s DI container via the standard provider/module pattern.

from lexigram.cli import CLIModule
# In your app module:
@module(
imports=[CLIModule.configure(CLIConfig(...))]
)
class AppModule(Module):
pass

CLIModule.configure() creates a DynamicModule that exports CLIApplicationProtocol. The stub() classmethod provides a no-op variant for testing.

PropertyValue
name"cli"
priorityProviderPriority.APPLICATION (40)
register()Binds CLIConfig singleton; delegates to CliContributorSubProvider
boot()Wires contributors via populate_cli_registries()
shutdown()Stateless — no-op
health_check()Always HEALTHY (no external dependencies)
sequenceDiagram
    participant App as App Bootstrap
    participant Provider as CLIProvider
    participant Sub as CliContributorSubProvider
    participant DIC as DI Container
    participant EP as Entry Points

    App->>Provider: CLIProvider(config=CLIConfig(...))
    Provider->>Sub: create CliContributorSubProvider()
    App->>Provider: register(di_container)
    Provider->>Sub: sub_provider.register(container)
    Sub->>DIC: bind CliContributorRegistry
    Sub->>DIC: bind GeneratorRegistry
    Sub->>DIC: bind CommandAssembler factory
    App->>Provider: boot(di_container)
    Provider->>Sub: sub_provider.boot(container)
    Sub->>DIC: resolve CliContributorRegistry
    Sub->>DIC: resolve GeneratorRegistry
    Sub->>EP: populate_cli_registries(contributor_registry, generator_registry)
    EP->>EP: load entry points (lexigram.cli.contributors)
    EP->>DIC: register all contributors & generators

A composed sub-provider that registers the full contribution system (registry, generator registry, assembler) as container singletons during register(), then discovers all entry points during boot().


The CLI resolves configuration through three prioritized sources:

flowchart LR
    A[1. --config / -c flag] --> D[load_config_yaml_async]
    B[2. LEX_CONFIG env var] --> D
    C[3. Walk up from CWD find application.yaml] --> D
    D --> E{File found?}
    E -->|Yes| F[Parse YAML + env interpolation]
    E -->|No| G[Raise ConfigNotFoundError]
    F --> H[dict[str, Any]]

Resolution order in find_config():

PrioritySourceExample
1--config / -c CLI flaglexigram dev --config /etc/app.yaml
2LEX_CONFIG env varLEX_CONFIG=/etc/app.yaml lexigram dev
3Walk up from CWD./application.yaml, ../application.yaml, …
  • YAML parsing — via yaml.safe_load() with aiofiles for async I/O
  • Environment interpolation${VAR} and ${VAR:default} patterns are resolved during load via interpolate_string()
  • Secret maskingconfig show masks keys containing secret, password, key, token by default; --reveal-secrets to bypass
class CLIConfig(BaseConfig):
config_section: ClassVar[str] = "cli"
default_template: str = "web-api"
default_database: str = "postgres"
color: bool = True
verbose: bool = False

Mapped from the [cli] section of application.yaml or via LEX_CLI__* environment variables (LEX_CLI__VERBOSE=true).


The contributor system is the primary extension mechanism. Packages register under the lexigram.cli.contributors entry-point group and provide generators, command groups, health checks, doctor checks, shell context, and lifecycle hooks.

The CoreCliContributor (in contributors/core.py) registers the two built-in generators: provider and test.

runtime = ContributorRuntime.from_entry_points()

Loads all lexigram.cli.contributors entry points with:

FeatureDescription
contributorsSuccessfully instantiated CliContributorProtocol objects
errorsdict[str, LoadError] — contributors that failed to load or instantiate
command_conflictsdict[str, CommandConflict] — collisions resolved by scope priority

When two contributors register commands with the same name, ContributorRuntime uses a scope-based winner system:

  1. Reserved names — certain names (search) are reserved for built-in or scoped packages; third-party attempts are rejected.
  2. Scope priority — packages in _SCOPE_DISTS (the canonical lexigram-* set) take priority over third-party packages.
  3. First-registered wins — ties within the same scope.

src/lexigram/cli/
├── __init__.py # Lazy-loaded exports
├── config.py # CLIConfig + ConfigManager (TOML)
├── constants.py # Version, env prefix, defaults
├── exceptions.py # CliError, ConfigNotFoundError, ProviderNotInstalledError
├── module.py # CLIModule — DI module for CLI
├── protocols.py # CommandProtocol, CLIRunnerProtocol, CLIApplicationProtocol
├── types.py # CommandType, CLIResult
├── assembly/
│ └── assembler.py # CommandAssembler — wires generators onto Typer
├── commands/ # 15 command groups + meta commands
├── contributors/
│ ├── base.py # BaseCliContributor
│ ├── core.py # CoreCliContributor (built-in generators)
│ ├── registry.py # CliContributorRegistry
│ ├── discovery.py # populate_cli_registries() — entry-point loader
│ └── runtime.py # ContributorRuntime — load, error, conflict resolution
├── di/
│ ├── provider.py # CLIProvider
│ └── sub_providers/
│ └── contributor.py # CliContributorSubProvider
├── generators/
│ ├── base.py # Re-exports GeneratorBase from lexigram.codegen
│ ├── provider.py # ProviderGenerator
│ ├── test.py # TestGenerator
│ └── field_parser.py # FieldSpec, parse_fields
├── lib/
│ ├── config_gen.py # PACKAGE_CONFIGS — introspect config models → YAML
│ ├── config_loader.py # find_config(), load_config_yaml_async()
│ ├── console.py # Rich Console (shared instance)
│ ├── entry_point.py # detect_factory_attr(), path_to_module()
│ ├── naming.py # to_camel_case, to_snake_case (re-exports)
│ └── templates.py # TemplateRenderer — Jinja2 wrapper
├── output/
│ └── manager.py # OutputManager — Rich / JSON / Quiet modes
├── registry/ # 22 registry modules (below)
└── templates/
├── project/ # Project scaffolding (web-api, full, api)
├── package/ # Extension package scaffolding
├── provider.py.jinja2 # Provider generator template
└── test_unit.py.jinja2 # Test generator template

The registry/ package provides pluggable, registry-pattern implementations for CLI subsystems:

ModuleKey ClassesPurpose
server.pyServerBackend, ServerRegistry, ServerManagerASGI server backends (uvicorn, hypercorn, granian, gunicorn)
database.pyDatabaseBackend, DatabaseRegistry, DatabaseConnectionDatabase-specific operations (SQLite, PostgreSQL, MySQL)
generator.pyCodeGenerator, GeneratorAdapter, GeneratorRegistryCode generator definition and dispatch
config.pyConfigValidator, ConfigValidatorRegistryConfig validation (secrets, production, required fields)
health.pyHealthCheck, HealthCheckRegistryStatic project health checks
hook.pyHook, HookRegistry, HookExecutorCLI lifecycle hooks
command.pyCommandEntry, CommandRegistryCommand metadata and categorization
provider.pyProvider, ProviderRegistry, ProviderInstallerlexigram add provider management
template.pyProjectTemplate, TemplateRegistry, ProjectBuilderProject scaffolding templates
task.pyTaskRunner, TaskRunnerRegistryTest/lint/typecheck runner dispatch
formatter.pyOutputFormatter, FormatterRegistryOutput serialization (JSON, YAML, table)
environment.pyEnvironment, EnvironmentRegistryEnvironment configuration profiles
inspect.pyInspector, InspectorRegistryRuntime introspection (stub)
secrets.pySecretsRegistry, SecretBackendSecret generation
telemetry.pyTelemetryRegistryTelemetry opt-in/out
completion.pyCompletionGenerator, CompletionRegistryShell completion scripts
version.pyVersionInfo, VersionRegistryPackage version management
presets.pyPreset, PresetRegistryProject preset combinations
migration.py(stub)Migration helpers
serializer.py(stub)Serialization helpers
watcher.py(stub)File change watchers

The unified output layer supports three modes:

# Default — Rich-formatted, human-readable
out = OutputManager()
out.success("Created file")
# JSON mode — machine-readable
out = OutputManager(json_mode=True)
out.success("Created file") # → {"status": "success", "message": "Created file"}
# Quiet mode — suppresses non-essential output
out = OutputManager(quiet=True)
out.info("...") # suppressed
out.error("fail") # shown

The handle_errors decorator wraps Typer commands to catch CliError (rendered with causes and suggestions) and unexpected exceptions (rendered with traceback hint):

@app.command()
@handle_errors
def my_command():
# CliError → friendly output with causes/suggestions
# Generic Exception → error + hint to use --debug
# typer.Exit → re-raised as-is

flowchart LR
    subgraph CLI[lexigram-cli]
        CE[CliError<br/>LEX_ERR_CLI_001]
        CNF[ConfigNotFoundError<br/>LEX_ERR_CLI_002]
        PNI[ProviderNotInstalledError<br/>LEX_ERR_CLI_003]
    end
    subgraph Contracts[lexigram-contracts]
        LE[LexigramError]
    end
    CE --> LE
    CNF --> CE
    PNI --> CE
    CLI --> |handle_errors decorator| O[OutputManager<br/>causes · suggestions]
CodeExceptionDescription
LEX_ERR_CLI_001CliErrorBase CLI exception with causes and suggestions lists
LEX_ERR_CLI_002ConfigNotFoundErrorapplication.yaml not found
LEX_ERR_CLI_003ProviderNotInstalledErrorRequired provider package missing

All extend LexigramError from lexigram-contracts.


lexigram/cli/di/provider.py
class CLIProvider(Provider):
async def register(self, container):
if self._cli_config is not None:
container.singleton(CLIConfig, self._cli_config)
await self._contributor_sub_provider.register(container)
# lexigram/cli/di/sub_providers/contributor.py
class CliContributorSubProvider:
async def register(self, container):
container.singleton(CliContributorRegistry, CliContributorRegistry())
container.singleton(GeneratorRegistry, GeneratorRegistry())
container.singleton(CommandAssembler, factory=CommandAssembler)

The CLI is registered as a framework module:

from lexigram.cli import CLIModule
from lexigram.cli.config import CLIConfig
@module(imports=[CLIModule.configure(CLIConfig(...))])
class AppModule(Module):
pass

The contributor system distinguishes between scoped (canonical lexigram-* packages) and third-party contributors. This is enforced via _SCOPE_DISTS in contributors/runtime.py:

_SCOPE_DISTS = frozenset({
"lexigram", "lexigram-contracts", "lexigram-web", "lexigram-sql",
"lexigram-cache", "lexigram-auth", "lexigram-events", "lexigram-ai",
# ... ~30 canonical packages
})
MechanismBehavior
_SCOPE_DISTSCanonical packages win command name conflicts over third-party packages
_RESERVED_COMMANDSCertain names (search) are reserved — only scoped packages may register them
BUILTIN_COMMANDSBuilt-in Typer groups always win; contributed commands with matching names are skipped
Conflict loggingcontrib list and contrib check report all resolved conflicts

PointMechanismLocation
Custom generatorRegister GeneratorDefinition via CliContributorProtocol.get_generators()Any package
Custom command groupRegister CommandContribution via CliContributorProtocol.get_commands()Any package
Custom project templateAdd directory under templates/project/This package
Custom server backendSubclass ServerBackend, register via ServerRegistryAny package
Custom database backendSubclass DatabaseBackend, register via DatabaseRegistryAny package
Custom config validatorSubclass ConfigValidator, register via ConfigValidatorRegistryAny package
Health checkRegister HealthCheckContribution via CliContributorProtocolAny package
Doctor checkRegister DoctorCheckContribution via CliContributorProtocolAny package
Shell context injectionRegister ShellContextContribution via CliContributorProtocolAny package
CLI lifecycle hooksRegister HookContribution via CliContributorProtocolAny package
Output formatSubclass OutputFormatter, register via FormatterRegistryAny package
Task runnerSubclass TaskRunner, register via TaskRunnerRegistryAny package
Environment profileSubclass Environment, register via EnvironmentRegistryThis package

SymbolDescription
ENV_PREFIXLEX_CLI__
DEFAULT_OUTPUT_FORMAT"text"
DEFAULT_LOG_LEVEL"INFO"
DEFAULT_CONFIG_FILE"application.yaml"
FORMAT_TEXT, FORMAT_JSON, FORMAT_TABLEOutput format constants
ENTRY_POINT_GROUP"lexigram.cli.contributors"
__version__Package version from importlib.metadata