Code Organization
How the gl CLI is structured, which libraries to use, and how to extend it.
Library Standards
Python — Canonical Stack
Python is the default language for all CLI tooling. The following libraries form the canonical stack:
| Concern | Library | Version | Notes |
|---|---|---|---|
| CLI framework | click | >=8.1 | Groups, commands, options, arguments |
| Terminal output | rich | >=13.0 | Tables, colors, progress, panels, prompts |
| Structured logs | structlog | >=24.0 | JSON + console renderers |
| Validation | pydantic | >=2.9 | Config models, extra="forbid" |
| Config | pydantic-settings | >=2.6 | Env-var-based settings with prefix |
| HTTP client | httpx | >=0.27 | Async downloads, health checks |
No other CLI framework (Typer, Argparse, Fire, etc.) may be used without an ADR.
Go — When Applicable
Go is used only for standalone tools that need static compilation or live within the Go service ecosystem:
| Concern | Library | Version | Notes |
|---|---|---|---|
| CLI framework | cobra | latest stable | Only for standalone Go CLI tools |
| Terminal styling | lipgloss | latest stable | Charmbracelet styling library |
| Structured logs | charmbracelet/log | latest stable | Or zerolog for service-adjacent tools |
| TUI (optional) | bubbletea | latest stable | Interactive terminal UIs |
Bash — Constrained
Bash scripts are allowed only for wrappers under 20 lines that delegate to another tool.
Rules:
- Must start with
#!/usr/bin/env bashandset -euo pipefail - Must not contain business logic, loops over data, or conditional branching beyond simple guards
- Color output uses standard variables (
RED,GREEN,YELLOW,NC) - If the script grows beyond 20 lines, it must be rewritten in Python
Version Pinning
- All CLI dependencies are pinned in
pyproject.tomlusing minimum version specifiers (>=X.Y) - Lock files (
uv.lock) are committed to the repo - Dependency updates use the monorepo's
depscommit scope
Language Selection Matrix
When deciding which language to use for a new tool, consult this matrix:
| Criterion | Python | Go | Bash | TypeScript |
|---|---|---|---|---|
| Default for new CLI tools | ✓ | |||
| Compute-heavy processing (large file transforms) | ✓ | |||
| Standalone binary distribution needed | ✓ | |||
| Gateway or auth-adjacent tooling | ✓ | |||
| Simple wrapper (<20 LOC) around existing tool | ✓ | |||
| Nx workspace generators / scaffolding | ✓ | |||
| Needs Rich terminal output (tables, progress) | ✓ | |||
| Needs Pydantic validation | ✓ | |||
| Needs async HTTP | ✓ | |||
| Quick prototype / scripting | ✓ |
Decision rule: Default to Python. Choose Go only when the tool requires static compilation, extreme performance, or lives within the Go service ecosystem. Choose Bash only for trivial wrappers. Choose TypeScript only for Nx generators.
Package Layout
The gl CLI lives at tools/cli/ with this structure:
tools/cli/
├── pyproject.toml # Package definition + entry point
├── uv.lock # Locked dependencies
├── src/gospelib_cli/
│ ├── __init__.py # Package version
│ ├── main.py # Root Click group + dashboard
│ ├── config.py # CLIConfig (pydantic-settings)
│ ├── logging.py # configure_logging()
│ ├── output.py # OutputFormatter (json, table, csv, quiet)
│ ├── errors.py # Error display + did-you-mean
│ ├── groups/ # One module per command group
│ │ ├── __init__.py # Barrel — imports all groups
│ │ ├── dev.py # gl dev *
│ │ ├── infra.py # gl infra *
│ │ ├── test.py # gl test *
│ │ ├── lint.py # gl lint *
│ │ ├── format.py # gl format *
│ │ ├── codegen.py # gl codegen *
│ │ ├── db.py # gl db *
│ │ ├── download.py # gl download * (delegates to gospelib-download)
│ │ ├── ingest.py # gl ingest * (delegates to gospelib-ingest)
│ │ ├── health.py # gl health
│ │ ├── setup.py # gl setup
│ │ ├── config_cmd.py # gl config *
│ │ └── doctor.py # gl doctor
│ └── lib/ # Shared utilities
│ ├── __init__.py
│ ├── process.py # Subprocess runners with output capture
│ ├── docker.py # Docker/Compose interaction helpers
│ ├── discovery.py # Service/project auto-discovery
│ └── terminal.py # Terminal detection, width, color support
└── tests/
├── conftest.py
├── test_main.py
├─ ─ test_output.py
└── groups/
├── test_dev.py
├── test_infra.py
└── ...
Click Group Registration Pattern
Every command group module exports a single click.Group at module level. Here's the pattern for a group file:
# groups/dev.py
import click
@click.group()
def dev() -> None:
"""Start, stop, and manage development services."""
@dev.command()
def start() -> None:
"""Start development services."""
...
@dev.command()
def stop() -> None:
"""Stop running services."""
...
The root main.py registers all groups:
# main.py
import click
from gospelib_cli.groups import dev, infra, test, lint # etc.
@click.group(invoke_without_command=True)
@click.option("--no-color", is_flag=True, help="Disable colored output.")
@click.option("-v", "--verbose", count=True, help="Increase verbosity.")
@click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential output.")
@click.option("--log-file", type=click.Path(), help="Write logs to this file.")
@click.version_option()
@click.pass_context
def cli(ctx: click.Context, no_color: bool, verbose: int, quiet: bool, log_file: str | None) -> None:
"""GospeLib developer CLI — unified entry point for all GospeLib workflows."""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["quiet"] = quiet
ctx.obj["no_color"] = no_color
ctx.obj["log_file"] = log_file
configure_logging(verbosity=verbose if not quiet else -1, log_file=log_file)
if ctx.invoked_subcommand is None:
show_dashboard(ctx)
cli.add_command(dev.dev)
cli.add_command(infra.infra)
# ... register all groups
Adding a New Command Group
- Create
src/gospelib_cli/groups/<name>.py - Define a
@click.group()function named after the group - Add commands as
@<group>.command()decorated functions - Import and register in
main.pyviacli.add_command(<group>) - Add to the
groups/__init__.pybarrel import - Add tests in
tests/groups/test_<name>.py
Adding a New Command to an Existing Group
- Open
src/gospelib_cli/groups/<group>.py - Add a
@<group>.command()decorated function - Follow flag naming conventions and exit codes from the Help & Commands page
- Add tests
Testing Patterns
CLI tests use Click's CliRunner for isolated, subprocess-free testing:
from click.testing import CliRunner
from gospelib_cli.main import cli
def test_health_check_runs() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["health"])
assert result.exit_code == 0
assert "Service Health" in result.output
def test_dev_start_json_output() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["dev", "status", "--output", "json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "data" in data
Test files follow the test_<group>.py naming convention and live in tests/groups/. Shared fixtures (mock Docker, mock subprocess, etc.) go in conftest.py.
Shared Utilities
The lib/ directory contains reusable helpers shared across command groups:
| Module | Responsibility |
|---|---|
process.py | Run subprocesses with output capture, timeout, signal forwarding |
docker.py | Check Docker availability, parse docker compose ps, start/stop |
discovery.py | Discover services, detect running processes, find project root |
terminal.py | Terminal width detection, NO_COLOR check, capability detection |
uv Usage Standards
Never use bare pip install, python -m, or direct interpreter calls. Always go through uv.
Execution Rules
| Action | Correct | Forbidden |
|---|---|---|
| Run a CLI tool | uv run gl <command> | python3 -m gospelib_cli, python gl |
| Install dependencies | uv sync | pip install, pip install -r |
| Add a dependency | uv add <package> | pip install <package> |
| Run corpus downloader | uv run gospelib-download <cmd> | python -m gospelib_download |
| Run ingest pipeline | uv run gospelib-ingest <cmd> | python -m gospelib_ingest |
| Run tests | uv run pytest | python -m pytest, pytest |
| Run linter | uv run ruff check . | ruff check . (unless globally installed) |
| Run type checker | uv run mypy . | mypy . (unless globally installed) |
Entry Points
Every Python CLI package declares its entry point in pyproject.toml:
[project.scripts]
gl = "gospelib_cli.main:cli"
This makes the command available as uv run gl and installs a shim in .venv/bin/gl.
Dependency Declaration
[project]
name = "gospelib-cli"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"click>=8.1",
"rich>=13.0",
"structlog>=24.0",
"pydantic>=2.9",
"pydantic-settings>=2.6",
"httpx>=0.27",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=6.0",
"ruff>=0.8",
"mypy>=1.13",
]
Virtual Environments
Each Python tool package (tools/cli/, tools/corpus-downloader/, services/ingest/) maintains its own .venv managed by uv. The uv.lock file is always committed.