diff --git a/features/steps/tdd_tool_cli_bootstrap_steps.py b/features/steps/tdd_tool_cli_bootstrap_steps.py new file mode 100644 index 000000000..00381316c --- /dev/null +++ b/features/steps/tdd_tool_cli_bootstrap_steps.py @@ -0,0 +1,116 @@ +"""Step definitions for TDD Issue #6885 — CLI registry bootstrap.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path + +from behave import given, then, when +from typer.testing import CliRunner + +import cleveragents.cli.bootstrap as cli_bootstrap +from cleveragents.application.container import reset_container +from cleveragents.cli.commands.tool import app as tool_app +from cleveragents.cli.commands.validation import app as validation_app +from cleveragents.config.settings import Settings + + +def _reset_settings() -> None: + """Reset singleton settings between scenarios.""" + + Settings.reset() + + +@given("a CLI runner without a bootstrapped registry database") +def step_no_bootstrap(context) -> None: + context.runner = CliRunner() + + reset_container() + _reset_settings() + + cli_bootstrap.reset_bootstrap_state() + + tmpdir = tempfile.mkdtemp(prefix="tdd_tool_cli_bootstrap_6885_") + db_path = Path(tmpdir) / "registry.db" + + context._tool_cli_tmpdir = tmpdir + context._tool_cli_db_path = db_path + + os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}" + + def _cleanup() -> None: + os.environ.pop("CLEVERAGENTS_DATABASE_URL", None) + cli_bootstrap.reset_bootstrap_state() + reset_container() + _reset_settings() + shutil.rmtree(tmpdir, ignore_errors=True) + + context.add_cleanup(_cleanup) + + +@when("I invoke tool list without prior bootstrap") +def step_invoke_tool_list(context) -> None: + context.result = context.runner.invoke(tool_app, ["list"]) + + +@when("I invoke validation add without prior bootstrap") +def step_invoke_validation_add(context) -> None: + config_path = Path(context._tool_cli_tmpdir) / "validation.yaml" + config_path.write_text( + """ +name: local/test-validation +description: temporary validation for TDD issue 6885 +source: custom +mode: informational +code: | + def run(inputs): + return {"passed": True} +""".strip() + ) + + context.result = context.runner.invoke( + validation_app, + [ + "add", + "--config", + str(config_path), + "--format", + "json", + ], + ) + + +@then("the tool list command should exit successfully") +def step_tool_list_exit_ok(context) -> None: + assert context.result.exit_code == 0, ( + f"Expected exit code 0, got {context.result.exit_code}.\n" + f"Output:\n{context.result.output}\n" + f"Exception: {getattr(context.result, 'exception', None)!r}" + ) + + +@then("the validation add command should exit successfully") +def step_validation_add_exit_ok(context) -> None: + assert context.result.exit_code == 0, ( + f"Expected exit code 0, got {context.result.exit_code}.\n" + f"Output:\n{context.result.output}\n" + f"Exception: {getattr(context.result, 'exception', None)!r}" + ) + + +@then("the tool list output should indicate that no tools are registered") +def step_tool_list_output(context) -> None: + output = context.result.output + assert "No tools found" in output, ( + f"Expected 'No tools found' in output.\nActual output:\n{output}" + ) + + +@then("the validation add output should report the registered validation in JSON") +def step_validation_add_output(context) -> None: + output = context.result.output + assert '"name": "local/test-validation"' in output, ( + "Expected the registered validation name in the JSON output." + ) diff --git a/features/tdd_tool_cli_bootstrap.feature b/features/tdd_tool_cli_bootstrap.feature new file mode 100644 index 000000000..988446556 --- /dev/null +++ b/features/tdd_tool_cli_bootstrap.feature @@ -0,0 +1,17 @@ +@tdd_issue @tdd_issue_6885 +Feature: TDD Issue #6885 — Tool CLI bootstraps database automatically + As a developer + I want `agents tool list` and `agents validation add` to work on a fresh install + So that users do not have to run a manual database upgrade before using the registry + + Scenario: Tool list command bootstraps the database automatically + Given a CLI runner without a bootstrapped registry database + When I invoke tool list without prior bootstrap + Then the tool list command should exit successfully + And the tool list output should indicate that no tools are registered + + Scenario: Validation add command bootstraps the database automatically + Given a CLI runner without a bootstrapped registry database + When I invoke validation add without prior bootstrap + Then the validation add command should exit successfully + And the validation add output should report the registered validation in JSON diff --git a/src/cleveragents/cli/bootstrap.py b/src/cleveragents/cli/bootstrap.py new file mode 100644 index 000000000..8db99f921 --- /dev/null +++ b/src/cleveragents/cli/bootstrap.py @@ -0,0 +1,50 @@ +"""CLI bootstrap helpers. + +Ensures process-wide initialization for CLI commands that depend on +persistence-backed registries by running Alembic migrations exactly once per +process. +""" + +from __future__ import annotations + +from threading import Lock + +_database_bootstrapped = False +_bootstrap_lock = Lock() + + +def ensure_cli_database_bootstrapped(force: bool = False) -> None: + """Ensure CLI database schema exists and migrations are applied.""" + + global _database_bootstrapped + + if _database_bootstrapped and not force: + return + + with _bootstrap_lock: + if _database_bootstrapped and not force: + return + + from cleveragents.application.container import get_database_url + from cleveragents.infrastructure.database.migration_runner import ( + MigrationRunner, + ) + + runner = MigrationRunner(get_database_url()) + runner.init_or_upgrade(require_confirmation=False) + + _database_bootstrapped = True + + +def reset_bootstrap_state() -> None: + """Reset the bootstrap state flag. + + .. warning:: **Test use only.** Do not call in production code paths — + resetting bootstrap state mid-flight can cause inconsistent database + initialisation state. + """ + global _database_bootstrapped + _database_bootstrapped = False + + +__all__ = ["ensure_cli_database_bootstrapped", "reset_bootstrap_state"] diff --git a/src/cleveragents/cli/commands/tool.py b/src/cleveragents/cli/commands/tool.py index 428082b35..9c46b5c99 100644 --- a/src/cleveragents/cli/commands/tool.py +++ b/src/cleveragents/cli/commands/tool.py @@ -55,6 +55,7 @@ import yaml from rich.panel import Panel from rich.table import Table +from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( @@ -76,6 +77,8 @@ def _get_tool_registry_service() -> Any: """Get the ToolRegistryService from the container.""" from cleveragents.application.container import get_container + ensure_cli_database_bootstrapped() + container = get_container() database_url: str = container.database_url() diff --git a/src/cleveragents/cli/commands/validation.py b/src/cleveragents/cli/commands/validation.py index 4e6346c04..eb2bb65c1 100644 --- a/src/cleveragents/cli/commands/validation.py +++ b/src/cleveragents/cli/commands/validation.py @@ -56,6 +56,7 @@ import yaml from rich.console import Console from rich.panel import Panel +from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.core.exceptions import ( CleverAgentsError, @@ -81,6 +82,8 @@ def _get_tool_registry_service() -> Any: """ from cleveragents.application.container import get_container + ensure_cli_database_bootstrapped() + return get_container().tool_registry_service()