Skip to main content

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:

ConcernLibraryVersionNotes
CLI frameworkclick>=8.1Groups, commands, options, arguments
Terminal outputrich>=13.0Tables, colors, progress, panels, prompts
Structured logsstructlog>=24.0JSON + console renderers
Validationpydantic>=2.9Config models, extra="forbid"
Configpydantic-settings>=2.6Env-var-based settings with prefix
HTTP clienthttpx>=0.27Async downloads, health checks
No alternative CLI frameworks

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:

ConcernLibraryVersionNotes
CLI frameworkcobralatest stableOnly for standalone Go CLI tools
Terminal stylinglipglosslatest stableCharmbracelet styling library
Structured logscharmbracelet/loglatest stableOr zerolog for service-adjacent tools
TUI (optional)bubbletealatest stableInteractive 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 bash and set -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.toml using minimum version specifiers (>=X.Y)
  • Lock files (uv.lock) are committed to the repo
  • Dependency updates use the monorepo's deps commit scope

Language Selection Matrix

When deciding which language to use for a new tool, consult this matrix:

CriterionPythonGoBashTypeScript
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

  1. Create src/gospelib_cli/groups/<name>.py
  2. Define a @click.group() function named after the group
  3. Add commands as @<group>.command() decorated functions
  4. Import and register in main.py via cli.add_command(<group>)
  5. Add to the groups/__init__.py barrel import
  6. Add tests in tests/groups/test_<name>.py

Adding a New Command to an Existing Group

  1. Open src/gospelib_cli/groups/<group>.py
  2. Add a @<group>.command() decorated function
  3. Follow flag naming conventions and exit codes from the Help & Commands page
  4. 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:

ModuleResponsibility
process.pyRun subprocesses with output capture, timeout, signal forwarding
docker.pyCheck Docker availability, parse docker compose ps, start/stop
discovery.pyDiscover services, detect running processes, find project root
terminal.pyTerminal width detection, NO_COLOR check, capability detection

uv Usage Standards

Forbidden patterns

Never use bare pip install, python -m, or direct interpreter calls. Always go through uv.

Execution Rules

ActionCorrectForbidden
Run a CLI tooluv run gl <command>python3 -m gospelib_cli, python gl
Install dependenciesuv syncpip install, pip install -r
Add a dependencyuv add <package>pip install <package>
Run corpus downloaderuv run gospelib-download <cmd>python -m gospelib_download
Run ingest pipelineuv run gospelib-ingest <cmd>python -m gospelib_ingest
Run testsuv run pytestpython -m pytest, pytest
Run linteruv run ruff check .ruff check . (unless globally installed)
Run type checkeruv 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.