diff --git a/CHANGELOG.md b/CHANGELOG.md index d3482a1..d78e012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed +- Add a maintained, copyable five-minute starter template with explicit safe + customization points and installed-consumer validation (#244). + - Include `uv.lock` freshness in the authoritative local validation aggregate (#424). ## [0.5.1] - 2026-10-10 diff --git a/docs/index.md b/docs/index.md index 35ef02d..90b63d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,6 +58,8 @@ application. with the underlying parser and decide whether its lifecycle boundary fits. - Follow the [five-minute consumer quickstart](consumer-quickstart.md) for a minimal public `App`, `run_app()`, human output, and JSON invocation. +- Use the [starter template](starter-template.md) when you want a complete + copyable project with packaging and tests. - Start with the [adopter readiness guide](adopter-readiness.md) for a production evaluation. - See the [adoption and compatibility evidence guide](adoption-evidence.md) diff --git a/docs/starter-template.md b/docs/starter-template.md new file mode 100644 index 0000000..f0cd167 --- /dev/null +++ b/docs/starter-template.md @@ -0,0 +1,47 @@ +# Starter template + +The maintained starter is [`examples/minimal_cli`](../examples/minimal_cli/). +It is deliberately a copyable project rather than an interactive generator: +the first five minutes should expose the framework's public boundaries instead +of hiding them behind template machinery. + +## Five-minute proof + +Copy the directory into a clean location and run the same commands a new +consumer would use: + +```bash +cp -R examples/minimal_cli my-cli +cd my-cli +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +base-minimal --name Ada +python -m pytest +``` + +The starter has no private-repository dependency and uses only the public +`base_cli` facade. Its tests cover a successful invocation, lifecycle help, +and the missing-option error boundary. The repository's example workflow +installs and tests it as an installed consumer as well. + +## What to customize + +The command implementation belongs in +`src/minimal_cli/cli.py`; the package and console-script names belong in +`pyproject.toml`; product behavior belongs in `tests/` and `README.md`. +Keep `base_cli.run_app(app)` as the process boundary and do not import +framework-private modules. Add JSON, configuration, or optional integrations +only when the product needs them, following the corresponding contract docs. + +The starter's `base-cli>=0.5.1,<1.0` requirement is a release-line guard for +the current pre-1.0 framework. It should be updated deliberately when the +consumer adopts base-cli 1.0. + +## Why there is no `base-cli init` command yet + +The copyable starter is the 1.0 adoption surface. A generator would add a +second templating and upgrade contract before there is enough consumer +feedback to stabilize it. Interactive scaffolding remains a follow-up design +item; it must not become a hidden dependency of the core package. diff --git a/examples/README.md b/examples/README.md index 61ba0db..4172224 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,7 @@ directory. | Example | Demonstrates | Launcher | | --- | --- | --- | -| [`minimal_cli`](minimal_cli/README.md) | A single `base_cli.App` command | `base-minimal` | +| [`minimal_cli`](minimal_cli/README.md) | The five-minute copyable starter and a single `base_cli.App` command | `base-minimal` | | [`nested_click_app`](nested_click_app/README.md) | Native Click groups and plugin discovery | `base-nested` | | [`typer_app`](typer_app/README.md) | Typed Typer commands through the optional adapter | `base-typer` | | [`automation_observability_app`](automation_observability_app/README.md) | JSON output, dry runs, Rich, and telemetry | `base-automation` | diff --git a/examples/automation_observability_app/pyproject.toml b/examples/automation_observability_app/pyproject.toml index a3c7d40..007f9c3 100644 --- a/examples/automation_observability_app/pyproject.toml +++ b/examples/automation_observability_app/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Automation and observability base-cli reference application" requires-python = ">=3.10" dependencies = [ - "base-cli>=0.3", + "base-cli>=0.5.1,<1.0", "click>=8.1", ] diff --git a/examples/minimal_cli/README.md b/examples/minimal_cli/README.md index 2eedd91..032cfca 100644 --- a/examples/minimal_cli/README.md +++ b/examples/minimal_cli/README.md @@ -1,14 +1,56 @@ -# Minimal CLI +# Minimal CLI starter -This is the smallest complete `base-cli` application: one typed command, -standard lifecycle flags, file logging, cleanup, and a console-script entry -point. It is a good starting point for an internal tool or a new product. +This is the maintained, copyable starter for a new `base-cli` application. It +is intentionally small: one typed command, standard lifecycle flags, file +logging, cleanup, and a console-script entry point. It is a good starting +point for an internal tool or a new product. + +The starter is a reference template, not a code generator. Copy the directory +into your project, run the normal install and test commands below, and then +replace the command implementation at `src/minimal_cli/cli.py`. + +## Start in five minutes + +From the root of a `base-cli` checkout: + +```bash +cp -R examples/minimal_cli my-cli +cd my-cli +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +base-minimal --name Ada +python -m pytest +``` + +The copy is a complete installable project. It exercises the same package, +console-script, and test boundaries that a production consumer uses; it does +not import the repository checkout. + +## Safe customization points + +- `src/minimal_cli/cli.py`: replace `greet()` with the product's commands and + keep `base_cli.run_app(app)` as the process boundary. +- `src/minimal_cli/__init__.py`: update the consumer package version and + metadata when the product is released. +- `pyproject.toml`: rename the distribution and console script, then pin the + supported `base-cli` release line. +- `tests/test_cli.py`: keep a happy-path test and add the product's error and + JSON-contract tests. +- `README.md`: document the product's configuration, output, completion, + release, and troubleshooting behavior. + +Keep framework internals out of the consumer. Click remains the parser and +the public `base_cli` facade is the supported integration boundary. ## Install +From the copied project directory, use the editable install so changes to +`src/minimal_cli/` are immediately reflected when you rerun the command: + ```bash -python -m pip install "base-cli>=0.3" -python -m pip install . +python -m pip install -e ".[dev]" base-minimal --help ``` @@ -29,7 +71,6 @@ errors follow the versioned JSON contract documented by base-cli. ## Tests ```bash -python -m pip install "base-cli[dev]" python -m pytest tests ``` diff --git a/examples/minimal_cli/pyproject.toml b/examples/minimal_cli/pyproject.toml index d171f32..dbc7877 100644 --- a/examples/minimal_cli/pyproject.toml +++ b/examples/minimal_cli/pyproject.toml @@ -7,7 +7,10 @@ name = "base-cli-reference-minimal" version = "0.1.0" description = "Minimal production-style base-cli reference application" requires-python = ">=3.10" -dependencies = ["base-cli>=0.3"] +dependencies = ["base-cli>=0.5.1,<1.0"] + +[project.optional-dependencies] +dev = ["pytest>=8,<9"] [project.scripts] base-minimal = "minimal_cli.cli:main" diff --git a/examples/minimal_cli/tests/test_cli.py b/examples/minimal_cli/tests/test_cli.py index 540e7a2..72fa756 100644 --- a/examples/minimal_cli/tests/test_cli.py +++ b/examples/minimal_cli/tests/test_cli.py @@ -24,3 +24,10 @@ def test_help_exposes_lifecycle_options() -> None: assert result.exit_code == 0, result.output assert "--name" in result.stdout assert "--environment" in result.stdout + + +def test_missing_name_is_an_actionable_production_error() -> None: + result = base_cli.testing.invoke(app, []) + + assert result.exit_code != 0 + assert "Missing option '--name'" in result.stderr diff --git a/examples/nested_click_app/pyproject.toml b/examples/nested_click_app/pyproject.toml index 4af6106..8bcbeb6 100644 --- a/examples/nested_click_app/pyproject.toml +++ b/examples/nested_click_app/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Nested Click and plugin-discovery base-cli reference application" requires-python = ">=3.10" dependencies = [ - "base-cli>=0.3", + "base-cli>=0.5.1,<1.0", "click>=8.1", ] diff --git a/examples/typer_app/pyproject.toml b/examples/typer_app/pyproject.toml index a73345f..1aa122f 100644 --- a/examples/typer_app/pyproject.toml +++ b/examples/typer_app/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Typed Typer base-cli reference application" requires-python = ">=3.10" dependencies = [ - "base-cli>=0.3", + "base-cli>=0.5.1,<1.0", "typer>=0.25.1,<0.28", ] diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 708b8a1..07c563e 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -45,6 +45,7 @@ "security-threat-model.md", "schemas.md", "strict-json-consumer.md", + "starter-template.md", "typer-adapter.md", "user-config-typing.md", } diff --git a/scripts/validate_examples.py b/scripts/validate_examples.py index cc5fdbc..25d8b3f 100644 --- a/scripts/validate_examples.py +++ b/scripts/validate_examples.py @@ -24,6 +24,7 @@ "Operational troubleshooting", ) SCRIPT_PATTERN = re.compile(r"^\s*base-[a-z0-9-]+\s*=\s*\"[a-zA-Z0-9_.]+:[a-zA-Z0-9_]+\"\s*$", re.MULTILINE) +BASE_CLI_REQUIREMENT = "base-cli>=0.5.1,<1.0" def fail(message: str) -> None: @@ -50,7 +51,9 @@ def validate_example(root: Path, name: str) -> None: if not required.exists(): fail(f"{required.relative_to(root)} is required") metadata = pyproject.read_text(encoding="utf-8") - if "base-cli" not in metadata or "[project.scripts]" not in metadata: + if BASE_CLI_REQUIREMENT not in metadata: + fail(f"{pyproject.relative_to(root)} must require {BASE_CLI_REQUIREMENT!r}") + if "[project.scripts]" not in metadata: fail(f"{pyproject.relative_to(root)} must declare base-cli and a console script") if not SCRIPT_PATTERN.search(metadata): fail(f"{pyproject.relative_to(root)} has no base-* console script") diff --git a/tests/test_starter_template.py b/tests/test_starter_template.py new file mode 100644 index 0000000..c47b719 --- /dev/null +++ b/tests/test_starter_template.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).parents[1] +STARTER = ROOT / "examples" / "minimal_cli" +STARTER_DOC = ROOT / "docs" / "starter-template.md" + + +def test_starter_has_the_copyable_project_boundaries() -> None: + for relative in ( + "README.md", + "pyproject.toml", + "src/minimal_cli/cli.py", + "src/minimal_cli/__init__.py", + "tests/test_cli.py", + ): + assert (STARTER / relative).is_file(), relative + + +def test_starter_pins_the_current_pre_one_release_line() -> None: + metadata = (STARTER / "pyproject.toml").read_text(encoding="utf-8") + assert "base-cli>=0.5.1,<1.0" in metadata + + +def test_starter_docs_explain_copying_and_safe_customization() -> None: + text = STARTER_DOC.read_text(encoding="utf-8") + for required in ( + "examples/minimal_cli", + 'python -m pip install -e ".[dev]"', + "python -m pytest", + "base_cli.run_app(app)", + "no `base-cli init` command yet", + ): + assert required in text