From c781848cb6c2f5adf8862d2463a65641e112e44b Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Mon, 2 Mar 2026 07:54:20 +0000 Subject: [PATCH] feat(interfaces): add server client stubs Add protocol stubs for remote server communication infrastructure: - ServerClient: Health check and version negotiation protocol - RemoteExecutionClient: Remote plan execution and status protocol - AuthClient: Authentication and token management protocol - StubServerClient/StubRemoteExecutionClient/StubAuthClient: Stub implementations raising NotImplementedError for all methods - ServerConnectionConfig: Validated config model (server_url, namespace, auth_token_ref, tls_verify) - Config keys: core.server_url, core.server_namespace, core.server_tls_verify - CLI: agents connect stub command with explicit warning - CLI: agents info shows Server Mode (disabled/stubbed) ISSUES CLOSED: #201 --- benchmarks/server_stub_bench.py | 88 ++++ docs/reference/server_client_stubs.md | 83 ++++ features/server_client_stubs.feature | 183 +++++++ .../steps/config_service_coverage_steps.py | 2 +- features/steps/server_client_stubs_steps.py | 447 ++++++++++++++++++ robot/config_resolution.robot | 2 +- robot/helper_config_resolution.py | 10 +- robot/helper_server_stubs.py | 174 +++++++ robot/server_stubs.robot | 49 ++ src/cleveragents/acp/__init__.py | 20 + src/cleveragents/acp/clients.py | 197 ++++++++ src/cleveragents/acp/server_config.py | 54 +++ .../application/services/config_service.py | 18 + src/cleveragents/cli/commands/server.py | 214 +++++++++ src/cleveragents/cli/commands/system.py | 6 +- src/cleveragents/cli/main.py | 7 + vulture_whitelist.py | 13 + 17 files changed, 1561 insertions(+), 6 deletions(-) create mode 100644 benchmarks/server_stub_bench.py create mode 100644 docs/reference/server_client_stubs.md create mode 100644 features/server_client_stubs.feature create mode 100644 features/steps/server_client_stubs_steps.py create mode 100644 robot/helper_server_stubs.py create mode 100644 robot/server_stubs.robot create mode 100644 src/cleveragents/acp/clients.py create mode 100644 src/cleveragents/acp/server_config.py create mode 100644 src/cleveragents/cli/commands/server.py diff --git a/benchmarks/server_stub_bench.py b/benchmarks/server_stub_bench.py new file mode 100644 index 000000000..c64dde72a --- /dev/null +++ b/benchmarks/server_stub_bench.py @@ -0,0 +1,88 @@ +"""ASV benchmarks for server client stubs and connection config. + +Measures the performance of: +- ServerConnectionConfig construction and validation +- StubServerClient/StubRemoteExecutionClient/StubAuthClient instantiation +- Config key registry lookups for server.* keys +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload so ASV picks up the source tree version. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.acp.clients import ( # noqa: E402 + StubAuthClient, + StubRemoteExecutionClient, + StubServerClient, +) +from cleveragents.acp.server_config import ServerConnectionConfig # noqa: E402 +from cleveragents.application.services.config_service import _REGISTRY # noqa: E402 + + +# --------------------------------------------------------------------------- +# Config resolution benchmarks +# --------------------------------------------------------------------------- + + +class ServerConfigResolutionSuite: + """Benchmark ServerConnectionConfig construction overhead.""" + + timeout = 60 + + def time_create_config_minimal(self) -> None: + ServerConnectionConfig(server_url="https://agents.example.com") + + def time_create_config_full(self) -> None: + ServerConnectionConfig( + server_url="https://agents.example.com", + namespace="my-team", + auth_token_ref="CLEVERAGENTS_SERVER_TOKEN", + tls_verify=False, + ) + + def time_registry_lookup_server_url(self) -> None: + _ = _REGISTRY["server.url"] + + def time_registry_lookup_server_tls(self) -> None: + _ = _REGISTRY["server.tls-verify"] + + def time_registry_lookup_server_namespace(self) -> None: + _ = _REGISTRY["server.namespace"] + + +# --------------------------------------------------------------------------- +# Stub client benchmarks +# --------------------------------------------------------------------------- + + +class StubClientInstantiationSuite: + """Benchmark stub client instantiation overhead.""" + + timeout = 60 + + def time_instantiate_stub_server_client(self) -> None: + StubServerClient() + + def time_instantiate_stub_execution_client(self) -> None: + StubRemoteExecutionClient() + + def time_instantiate_stub_auth_client(self) -> None: + StubAuthClient() + + def time_instantiate_all_stubs(self) -> None: + StubServerClient() + StubRemoteExecutionClient() + StubAuthClient() diff --git a/docs/reference/server_client_stubs.md b/docs/reference/server_client_stubs.md new file mode 100644 index 000000000..b44267739 --- /dev/null +++ b/docs/reference/server_client_stubs.md @@ -0,0 +1,83 @@ +# Server Client Stubs + +## Overview + +CleverAgents defines three protocol interfaces for server communication. In +the current release all implementations are **stubs** that raise +`NotImplementedError`. When server mode is implemented as a separate project +the concrete clients will replace these stubs. + +## Client Protocols + +### ServerClient + +Health check and version negotiation with the server. + +| Method | Return | Description | +|--------|--------|-------------| +| `health_check()` | `bool` | Check server health | +| `get_version()` | `str` | Get server version | + +### RemoteExecutionClient + +Remote plan execution and status tracking. + +| Method | Return | Description | +|--------|--------|-------------| +| `execute_plan(plan_id)` | `dict` | Submit plan for remote execution | +| `get_plan_status(plan_id)` | `dict` | Query remote plan status | +| `cancel_plan(plan_id)` | `bool` | Cancel remote plan execution | + +### AuthClient + +Authentication and token management. + +| Method | Return | Description | +|--------|--------|-------------| +| `authenticate(token)` | `bool` | Authenticate with server | +| `validate_token(token)` | `bool` | Validate token validity | + +## Stub Implementations + +- `StubServerClient` — raises `NotImplementedError` for all methods +- `StubRemoteExecutionClient` — validates arguments, then raises + `NotImplementedError` +- `StubAuthClient` — validates arguments, then raises `NotImplementedError` + +## ServerConnectionConfig + +Validated Pydantic model for server connection parameters. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `server_url` | `str` | *(required)* | Server URL (must be `http://` or `https://`) | +| `namespace` | `str` | `"default"` | Namespace on the server | +| `auth_token_ref` | `str \| None` | `None` | Reference to auth token | +| `tls_verify` | `bool` | `True` | Verify TLS certificates | + +## Config Keys + +| Key | Type | Default | Env Variable | Description | +|-----|------|---------|-------------|-------------| +| `server.url` | string | *(not set)* | `CLEVERAGENTS_SERVER_URL` | Server URL | +| `server.token` | string | *(not set)* | `CLEVERAGENTS_SERVER_TOKEN` | Auth token | +| `server.tls-verify` | bool | `true` | `CLEVERAGENTS_SERVER_TLS_VERIFY` | TLS verification | +| `server.namespace` | string | `"default"` | `CLEVERAGENTS_SERVER_NAMESPACE` | Server namespace | + +## Connection Behavior + +**Current mode: stub** + +When `server.url` is configured, the system reports `Server Mode: stubbed` in +`agents info` output. No actual network communication occurs. The `agents server +connect ` command persists the URL to configuration and prints an explicit +warning that server connection is not yet implemented. + +When `server.url` is not configured, the system reports `Server Mode: disabled`. + +## CLI Commands + +``` +agents server connect [--namespace NS] [--tls-verify|--no-tls-verify] +agents server status +``` diff --git a/features/server_client_stubs.feature b/features/server_client_stubs.feature new file mode 100644 index 000000000..134e282b8 --- /dev/null +++ b/features/server_client_stubs.feature @@ -0,0 +1,183 @@ +@phase2 @acp @server-stubs +Feature: Server Client Stubs and Connection Config + As a developer + I want protocol stubs for server communication + So that the server client interfaces are defined before implementation + + # ----------------------------------------------------------------------- + # ServerConnectionConfig validation + # ----------------------------------------------------------------------- + + Scenario: ServerConnectionConfig accepts valid HTTPS URL + When I create a ServerConnectionConfig with url "https://agents.example.com" + Then the config server_url should be "https://agents.example.com" + And the config namespace should be "default" + And the config tls_verify should be true + + Scenario: ServerConnectionConfig accepts valid HTTP URL + When I create a ServerConnectionConfig with url "http://localhost:8080" + Then the config server_url should be "http://localhost:8080" + + Scenario: ServerConnectionConfig accepts custom namespace + When I create a ServerConnectionConfig with url "https://agents.example.com" and namespace "my-team" + Then the config namespace should be "my-team" + + Scenario: ServerConnectionConfig accepts auth_token_ref + When I create a ServerConnectionConfig with url "https://agents.example.com" and auth_token_ref "CLEVERAGENTS_SERVER_TOKEN" + Then the config auth_token_ref should be "CLEVERAGENTS_SERVER_TOKEN" + + Scenario: ServerConnectionConfig rejects empty URL + When I try to create a ServerConnectionConfig with empty url + Then a server config validation error should be raised + + Scenario: ServerConnectionConfig rejects non-HTTP URL + When I try to create a ServerConnectionConfig with url "ftp://agents.example.com" + Then a server config validation error should be raised + + Scenario: ServerConnectionConfig rejects whitespace-only URL + When I try to create a ServerConnectionConfig with url " " + Then a server config validation error should be raised + + Scenario: ServerConnectionConfig rejects empty namespace + When I try to create a ServerConnectionConfig with url "https://ok.com" and empty namespace + Then a server config validation error should be raised + + Scenario: ServerConnectionConfig is frozen + When I create a ServerConnectionConfig with url "https://agents.example.com" + Then the config should be immutable + + Scenario: ServerConnectionConfig tls_verify defaults to true + When I create a ServerConnectionConfig with url "https://agents.example.com" + Then the config tls_verify should be true + + Scenario: ServerConnectionConfig with tls_verify false + When I create a ServerConnectionConfig with url "http://localhost:8080" and tls_verify false + Then the config tls_verify should be false + + # ----------------------------------------------------------------------- + # StubServerClient — all methods raise NotImplementedError + # ----------------------------------------------------------------------- + + Scenario: StubServerClient health_check raises NotImplementedError + Given a new StubServerClient + When I call health_check on the stub server client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubServerClient get_version raises NotImplementedError + Given a new StubServerClient + When I call get_version on the stub server client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + # ----------------------------------------------------------------------- + # StubRemoteExecutionClient — all methods raise NotImplementedError + # ----------------------------------------------------------------------- + + Scenario: StubRemoteExecutionClient execute_plan raises NotImplementedError + Given a new StubRemoteExecutionClient + When I call execute_plan with plan_id "PLAN001" on the stub execution client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubRemoteExecutionClient get_plan_status raises NotImplementedError + Given a new StubRemoteExecutionClient + When I call get_plan_status with plan_id "PLAN001" on the stub execution client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubRemoteExecutionClient cancel_plan raises NotImplementedError + Given a new StubRemoteExecutionClient + When I call cancel_plan with plan_id "PLAN001" on the stub execution client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubRemoteExecutionClient rejects empty plan_id + Given a new StubRemoteExecutionClient + When I call execute_plan with empty plan_id on the stub execution client + Then a ValueError should be raised for empty plan_id + + # ----------------------------------------------------------------------- + # StubAuthClient — all methods raise NotImplementedError + # ----------------------------------------------------------------------- + + Scenario: StubAuthClient authenticate raises NotImplementedError + Given a new StubAuthClient + When I call authenticate with token "tok_test" on the stub auth client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubAuthClient validate_token raises NotImplementedError + Given a new StubAuthClient + When I call validate_token with token "tok_test" on the stub auth client + Then a NotImplementedError should be raised with message containing "not yet implemented" + + Scenario: StubAuthClient rejects empty token + Given a new StubAuthClient + When I call authenticate with empty token on the stub auth client + Then a ValueError should be raised for empty token + + # ----------------------------------------------------------------------- + # Protocol conformance + # ----------------------------------------------------------------------- + + Scenario: StubServerClient conforms to ServerClient protocol + Then StubServerClient should be a runtime instance of ServerClient + + Scenario: StubRemoteExecutionClient conforms to RemoteExecutionClient protocol + Then StubRemoteExecutionClient should be a runtime instance of RemoteExecutionClient + + Scenario: StubAuthClient conforms to AuthClient protocol + Then StubAuthClient should be a runtime instance of AuthClient + + # ----------------------------------------------------------------------- + # Config keys registration + # ----------------------------------------------------------------------- + + Scenario: server.url config key is registered + Then config key "server.url" should be registered + + Scenario: server.token config key is registered + Then config key "server.token" should be registered + + Scenario: server.tls-verify config key is registered + Then config key "server.tls-verify" should be registered + + Scenario: server.namespace config key is registered + Then config key "server.namespace" should be registered + + Scenario: server.tls-verify defaults to true + Then config key "server.tls-verify" should have default true + + Scenario: server.namespace defaults to "default" + Then config key "server.namespace" should have default "default" + + # ----------------------------------------------------------------------- + # Config keys load from environment + # ----------------------------------------------------------------------- + + Scenario: server.url loads from CLEVERAGENTS_SERVER_URL environment + Then config key "server.url" should use env var "CLEVERAGENTS_SERVER_URL" + + Scenario: server.tls-verify loads from CLEVERAGENTS_SERVER_TLS_VERIFY environment + Then config key "server.tls-verify" should use env var "CLEVERAGENTS_SERVER_TLS_VERIFY" + + Scenario: server.namespace loads from CLEVERAGENTS_SERVER_NAMESPACE environment + Then config key "server.namespace" should use env var "CLEVERAGENTS_SERVER_NAMESPACE" + + # ----------------------------------------------------------------------- + # CLI connect command + # ----------------------------------------------------------------------- + + Scenario: CLI server connect prints stub warning + Given a temporary home directory for server tests + When I invoke server connect with url "https://stub.example.com" and format "json" + Then the server connect output should contain "not yet implemented" + And the server connect output should contain "stubbed" + + Scenario: CLI server connect rejects invalid URL + Given a temporary home directory for server tests + When I invoke server connect with url "ftp://bad-url" and format "json" + Then the server connect should exit with non-zero code + + # ----------------------------------------------------------------------- + # Server mode resolution + # ----------------------------------------------------------------------- + + Scenario: resolve_server_mode returns disabled when no URL configured + Given no server URL is configured + Then resolve_server_mode should return "disabled" diff --git a/features/steps/config_service_coverage_steps.py b/features/steps/config_service_coverage_steps.py index a709166ad..cf594b8d3 100644 --- a/features/steps/config_service_coverage_steps.py +++ b/features/steps/config_service_coverage_steps.py @@ -159,7 +159,7 @@ def step_verify_catalog_sections(context: Any) -> None: @then("the registry should contain at least 20 keys") def step_verify_catalog_count(context: Any) -> None: - # _build_catalog registers ~103 keys from the source + # _build_catalog registers ~105 keys from the source assert len(_REGISTRY) >= 20, f"Expected >=20 keys, got {len(_REGISTRY)}" diff --git a/features/steps/server_client_stubs_steps.py b/features/steps/server_client_stubs_steps.py new file mode 100644 index 000000000..2f1883bf3 --- /dev/null +++ b/features/steps/server_client_stubs_steps.py @@ -0,0 +1,447 @@ +"""Step definitions for server client stubs Behave scenarios.""" + +from __future__ import annotations + +import os +import tempfile + +from behave import given, then, use_step_matcher, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.acp.clients import ( + AuthClient, + RemoteExecutionClient, + ServerClient, + StubAuthClient, + StubRemoteExecutionClient, + StubServerClient, +) +from cleveragents.acp.server_config import ServerConnectionConfig +from cleveragents.application.services.config_service import _REGISTRY + +use_step_matcher("re") + + +# --------------------------------------------------------------------------- +# ServerConnectionConfig — creation +# --------------------------------------------------------------------------- + + +@when(r'I create a ServerConnectionConfig with url "(?P[^"]+)"') +def step_create_config(context: Context, url: str) -> None: + context.server_config = ServerConnectionConfig(server_url=url) + + +@when( + r'I create a ServerConnectionConfig with url "(?P[^"]+)" ' + r'and namespace "(?P[^"]+)"' +) +def step_create_config_ns(context: Context, url: str, ns: str) -> None: + context.server_config = ServerConnectionConfig(server_url=url, namespace=ns) + + +@when( + r'I create a ServerConnectionConfig with url "(?P[^"]+)" ' + r'and auth_token_ref "(?P[^"]+)"' +) +def step_create_config_auth(context: Context, url: str, ref: str) -> None: + context.server_config = ServerConnectionConfig(server_url=url, auth_token_ref=ref) + + +@when( + r'I create a ServerConnectionConfig with url "(?P[^"]+)" ' + r"and tls_verify false" +) +def step_create_config_tls_false(context: Context, url: str) -> None: + context.server_config = ServerConnectionConfig(server_url=url, tls_verify=False) + + +# --------------------------------------------------------------------------- +# ServerConnectionConfig — assertions +# --------------------------------------------------------------------------- + + +@then(r'the config server_url should be "(?P[^"]+)"') +def step_config_url(context: Context, expected: str) -> None: + assert context.server_config.server_url == expected, ( + f"Expected '{expected}', got '{context.server_config.server_url}'" + ) + + +@then(r'the config namespace should be "(?P[^"]+)"') +def step_config_namespace(context: Context, expected: str) -> None: + assert context.server_config.namespace == expected, ( + f"Expected '{expected}', got '{context.server_config.namespace}'" + ) + + +@then(r'the config auth_token_ref should be "(?P[^"]+)"') +def step_config_auth_ref(context: Context, expected: str) -> None: + assert context.server_config.auth_token_ref == expected, ( + f"Expected '{expected}', got '{context.server_config.auth_token_ref}'" + ) + + +@then(r"the config tls_verify should be true") +def step_config_tls_true(context: Context) -> None: + assert context.server_config.tls_verify is True + + +@then(r"the config tls_verify should be false") +def step_config_tls_false(context: Context) -> None: + assert context.server_config.tls_verify is False + + +@then(r"the config should be immutable") +def step_config_immutable(context: Context) -> None: + cfg = context.server_config + raised = False + try: + cfg.__setattr__("server_url", "http://changed.example.com") + except (ValidationError, AttributeError, TypeError): + raised = True + assert raised, "Setting attribute on frozen model should have raised an error" + + +# --------------------------------------------------------------------------- +# ServerConnectionConfig — validation errors +# --------------------------------------------------------------------------- + + +@when(r"I try to create a ServerConnectionConfig with empty url") +def step_create_config_empty_url(context: Context) -> None: + context.caught_error = None + try: + ServerConnectionConfig(server_url="") + except ValidationError as exc: + context.caught_error = exc + + +@when(r'I try to create a ServerConnectionConfig with url "(?P[^"]+)"') +def step_create_config_invalid_url(context: Context, url: str) -> None: + context.caught_error = None + try: + ServerConnectionConfig(server_url=url) + except ValidationError as exc: + context.caught_error = exc + + +@when( + r'I try to create a ServerConnectionConfig with url "(?P[^"]+)" ' + r"and empty namespace" +) +def step_create_config_empty_ns(context: Context, url: str) -> None: + context.caught_error = None + try: + ServerConnectionConfig(server_url=url, namespace="") + except ValidationError as exc: + context.caught_error = exc + + +@then(r"a server config validation error should be raised") +def step_server_config_validation_error(context: Context) -> None: + assert context.caught_error is not None, "Expected a ValidationError" + assert isinstance(context.caught_error, ValidationError), ( + f"Expected ValidationError, got {type(context.caught_error)}" + ) + + +# --------------------------------------------------------------------------- +# StubServerClient +# --------------------------------------------------------------------------- + + +@given(r"a new StubServerClient") +def step_stub_server_client(context: Context) -> None: + context.stub_server = StubServerClient() + + +@when(r"I call health_check on the stub server client") +def step_call_health_check(context: Context) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_server.health_check() + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when(r"I call get_version on the stub server client") +def step_call_get_version(context: Context) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_server.get_version() + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +# --------------------------------------------------------------------------- +# StubRemoteExecutionClient +# --------------------------------------------------------------------------- + + +@given(r"a new StubRemoteExecutionClient") +def step_stub_exec_client(context: Context) -> None: + context.stub_exec = StubRemoteExecutionClient() + + +@when( + r'I call execute_plan with plan_id "(?P[^"]+)" ' + r"on the stub execution client" +) +def step_call_execute_plan(context: Context, plan_id: str) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_exec.execute_plan(plan_id) + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when( + r'I call get_plan_status with plan_id "(?P[^"]+)" ' + r"on the stub execution client" +) +def step_call_get_plan_status(context: Context, plan_id: str) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_exec.get_plan_status(plan_id) + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when( + r'I call cancel_plan with plan_id "(?P[^"]+)" ' + r"on the stub execution client" +) +def step_call_cancel_plan(context: Context, plan_id: str) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_exec.cancel_plan(plan_id) + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when(r"I call execute_plan with empty plan_id on the stub execution client") +def step_call_execute_plan_empty(context: Context) -> None: + context.caught_error = None + try: + context.stub_exec.execute_plan("") + except ValueError as exc: + context.caught_error = exc + + +# --------------------------------------------------------------------------- +# StubAuthClient +# --------------------------------------------------------------------------- + + +@given(r"a new StubAuthClient") +def step_stub_auth_client(context: Context) -> None: + context.stub_auth = StubAuthClient() + + +@when(r'I call authenticate with token "(?P[^"]+)" on the stub auth client') +def step_call_authenticate(context: Context, token: str) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_auth.authenticate(token) + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when(r'I call validate_token with token "(?P[^"]+)" on the stub auth client') +def step_call_validate_token(context: Context, token: str) -> None: + context.caught_error = None + context.ctx_error = None + try: + context.stub_auth.validate_token(token) + except NotImplementedError as exc: + context.caught_error = exc + context.ctx_error = exc + + +@when(r"I call authenticate with empty token on the stub auth client") +def step_call_authenticate_empty(context: Context) -> None: + context.caught_error = None + try: + context.stub_auth.authenticate("") + except ValueError as exc: + context.caught_error = exc + + +# --------------------------------------------------------------------------- +# NotImplementedError and ValueError assertions +# --------------------------------------------------------------------------- + +# Note: the step "a NotImplementedError should be raised with message +# containing ..." is already defined in project_context_cli_steps.py +# and looks at context.ctx_error. Our stub steps store errors in +# context.caught_error, so we bridge by also writing to ctx_error +# in each step that catches NotImplementedError. + + +@then(r"a ValueError should be raised for empty plan_id") +def step_value_error_plan_id(context: Context) -> None: + assert context.caught_error is not None, "Expected ValueError" + assert isinstance(context.caught_error, ValueError), ( + f"Expected ValueError, got {type(context.caught_error)}" + ) + + +@then(r"a ValueError should be raised for empty token") +def step_value_error_token(context: Context) -> None: + assert context.caught_error is not None, "Expected ValueError" + assert isinstance(context.caught_error, ValueError), ( + f"Expected ValueError, got {type(context.caught_error)}" + ) + + +# --------------------------------------------------------------------------- +# Protocol conformance +# --------------------------------------------------------------------------- + + +@then(r"StubServerClient should be a runtime instance of ServerClient") +def step_server_protocol(context: Context) -> None: + assert isinstance(StubServerClient(), ServerClient) + + +@then( + r"StubRemoteExecutionClient should be a runtime instance of RemoteExecutionClient" +) +def step_exec_protocol(context: Context) -> None: + assert isinstance(StubRemoteExecutionClient(), RemoteExecutionClient) + + +@then(r"StubAuthClient should be a runtime instance of AuthClient") +def step_auth_protocol(context: Context) -> None: + assert isinstance(StubAuthClient(), AuthClient) + + +# --------------------------------------------------------------------------- +# Config key registration +# --------------------------------------------------------------------------- + + +@then(r'config key "(?P[^"]+)" should be registered') +def step_config_key_registered(context: Context, key: str) -> None: + assert key in _REGISTRY, f"Config key '{key}' not found in registry" + + +@then(r'config key "(?P[^"]+)" should have default true') +def step_config_key_default_true(context: Context, key: str) -> None: + entry = _REGISTRY[key] + assert entry.default is True, f"Expected True, got {entry.default}" + + +@then(r'config key "(?P[^"]+)" should have default "(?P[^"]+)"') +def step_config_key_default_str(context: Context, key: str, default: str) -> None: + entry = _REGISTRY[key] + assert str(entry.default) == default, f"Expected '{default}', got '{entry.default}'" + + +@then(r'config key "(?P[^"]+)" should use env var "(?P[^"]+)"') +def step_config_key_env_var(context: Context, key: str, env_var: str) -> None: + entry = _REGISTRY[key] + assert entry.env_var == env_var, ( + f"Expected env var '{env_var}', got '{entry.env_var}'" + ) + + +# --------------------------------------------------------------------------- +# CLI integration (isolated — uses a temporary HOME to avoid config bleed) +# --------------------------------------------------------------------------- + + +@given(r"a temporary home directory for server tests") +def step_tmp_home_for_server(context: Context) -> None: + """Set HOME to a fresh temporary directory so config writes are isolated.""" + context.server_test_home = tempfile.mkdtemp() + context.server_test_old_home = os.environ.get("HOME") + os.environ["HOME"] = context.server_test_home + + +@when( + r'I invoke server connect with url "(?P[^"]+)" ' + r'and format "(?P[^"]+)"' +) +def step_invoke_server_connect(context: Context, url: str, fmt: str) -> None: + """Run ``server connect --format `` and capture output.""" + import io + import sys + + from cleveragents.cli.main import main as cli_main + + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + context.server_connect_exit = cli_main( + ["server", "connect", url, "--format", fmt] + ) + except SystemExit as exc: + context.server_connect_exit = exc.code if exc.code is not None else 0 + finally: + sys.stdout = old_stdout + # Restore HOME + old_home = getattr(context, "server_test_old_home", None) + if old_home is not None: + os.environ["HOME"] = old_home + else: + os.environ.pop("HOME", None) + context.server_connect_output = captured.getvalue() + + +@then(r'the server connect output should contain "(?P[^"]+)"') +def step_server_connect_output_contains(context: Context, text: str) -> None: + assert text in context.server_connect_output, ( + f"Expected '{text}' in output: {context.server_connect_output!r}" + ) + + +@then(r"the server connect should exit with non-zero code") +def step_server_connect_non_zero(context: Context) -> None: + assert context.server_connect_exit != 0, ( + f"Expected non-zero exit, got {context.server_connect_exit}" + ) + + +# --------------------------------------------------------------------------- +# Server mode resolution +# --------------------------------------------------------------------------- + + +@given(r"no server URL is configured") +def step_no_server_url(context: Context) -> None: + # Ensure the env var is not set and use a clean temp HOME + os.environ.pop("CLEVERAGENTS_SERVER_URL", None) + context.clean_home = tempfile.mkdtemp() + context.old_home = os.environ.get("HOME") + os.environ["HOME"] = context.clean_home + + +@then(r'resolve_server_mode should return "(?P[^"]+)"') +def step_resolve_server_mode(context: Context, mode: str) -> None: + from cleveragents.cli.commands.server import resolve_server_mode + + try: + result = resolve_server_mode() + assert result == mode, f"Expected '{mode}', got '{result}'" + finally: + old_home = getattr(context, "old_home", None) + if old_home is not None: + os.environ["HOME"] = old_home + else: + os.environ.pop("HOME", None) diff --git a/robot/config_resolution.robot b/robot/config_resolution.robot index 5fafddc88..4b81f3f6b 100644 --- a/robot/config_resolution.robot +++ b/robot/config_resolution.robot @@ -9,7 +9,7 @@ ${HELPER} ${CURDIR}/helper_config_resolution.py *** Test Cases *** Config Service Registers All Spec Keys - [Documentation] Verify that the registry contains exactly 103 keys (102 spec + 1 skills) + [Documentation] Verify that the registry contains exactly 105 keys (102 spec + 1 skills + 2 server stubs) ${result}= Run Process ${PYTHON} ${HELPER} registry-key-count cwd=${WORKSPACE} Log ${result.stdout} Log ${result.stderr} diff --git a/robot/helper_config_resolution.py b/robot/helper_config_resolution.py index 9d3d82f94..ff267d531 100644 --- a/robot/helper_config_resolution.py +++ b/robot/helper_config_resolution.py @@ -46,13 +46,17 @@ def _cleanup(tmpdir: Path) -> None: def registry_key_count() -> None: - """Verify that the registry contains exactly 103 keys.""" + """Verify that the registry contains exactly 105 keys. + + 102 spec-defined + 1 skills extension + 2 server stub keys + (server.tls-verify, server.namespace). + """ registry = ConfigService.registry() count = len(registry) - if count == 103: + if count == 105: print("config-registry-key-count-ok") else: - print(f"FAIL: expected 103 keys, got {count}", file=sys.stderr) + print(f"FAIL: expected 105 keys, got {count}", file=sys.stderr) sys.exit(1) diff --git a/robot/helper_server_stubs.py b/robot/helper_server_stubs.py new file mode 100644 index 000000000..c879c2dd0 --- /dev/null +++ b/robot/helper_server_stubs.py @@ -0,0 +1,174 @@ +"""Helper script for server_stubs.robot smoke tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.acp.clients import ( # noqa: E402 + AuthClient, + RemoteExecutionClient, + ServerClient, + StubAuthClient, + StubRemoteExecutionClient, + StubServerClient, +) +from cleveragents.acp.server_config import ServerConnectionConfig # noqa: E402 +from cleveragents.application.services.config_service import _REGISTRY # noqa: E402 + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def stub_clients() -> None: + """Verify all stub clients raise NotImplementedError.""" + server = StubServerClient() + try: + server.health_check() + print("FAIL: should have raised NotImplementedError", file=sys.stderr) + sys.exit(1) + except NotImplementedError: + pass + + try: + server.get_version() + print("FAIL: should have raised NotImplementedError", file=sys.stderr) + sys.exit(1) + except NotImplementedError: + pass + + exec_client = StubRemoteExecutionClient() + try: + exec_client.execute_plan("PLAN001") + print("FAIL: should have raised NotImplementedError", file=sys.stderr) + sys.exit(1) + except NotImplementedError: + pass + + auth_client = StubAuthClient() + try: + auth_client.authenticate("tok_test") + print("FAIL: should have raised NotImplementedError", file=sys.stderr) + sys.exit(1) + except NotImplementedError: + pass + + # Verify protocol conformance + if not isinstance(server, ServerClient): + print("FAIL: StubServerClient not ServerClient", file=sys.stderr) + sys.exit(1) + if not isinstance(exec_client, RemoteExecutionClient): + print( + "FAIL: StubRemoteExecutionClient not RemoteExecutionClient", file=sys.stderr + ) + sys.exit(1) + if not isinstance(auth_client, AuthClient): + print("FAIL: StubAuthClient not AuthClient", file=sys.stderr) + sys.exit(1) + + print("server-stub-clients-ok") + + +def config_validation() -> None: + """Verify ServerConnectionConfig validation.""" + # Valid config + cfg = ServerConnectionConfig(server_url="https://agents.example.com") + if cfg.server_url != "https://agents.example.com": + print("FAIL: wrong url", file=sys.stderr) + sys.exit(1) + if cfg.namespace != "default": + print("FAIL: wrong namespace", file=sys.stderr) + sys.exit(1) + if cfg.tls_verify is not True: + print("FAIL: wrong tls_verify", file=sys.stderr) + sys.exit(1) + + # Invalid: empty URL + try: + ServerConnectionConfig(server_url="") + print("FAIL: should have rejected empty URL", file=sys.stderr) + sys.exit(1) + except Exception: + pass + + # Invalid: non-HTTP URL + try: + ServerConnectionConfig(server_url="ftp://bad.example.com") + print("FAIL: should have rejected ftp URL", file=sys.stderr) + sys.exit(1) + except Exception: + pass + + print("server-config-validation-ok") + + +def config_keys() -> None: + """Verify server config keys are registered.""" + required_keys = [ + "server.url", + "server.token", + "server.tls-verify", + "server.namespace", + ] + for key in required_keys: + if key not in _REGISTRY: + print(f"FAIL: '{key}' not in registry", file=sys.stderr) + sys.exit(1) + + # Check defaults + if _REGISTRY["server.tls-verify"].default is not True: + print("FAIL: server.tls-verify default not True", file=sys.stderr) + sys.exit(1) + if _REGISTRY["server.namespace"].default != "default": + print("FAIL: server.namespace default not 'default'", file=sys.stderr) + sys.exit(1) + + print("server-config-keys-ok") + + +def server_mode() -> None: + """Verify resolve_server_mode returns disabled.""" + # Ensure no server URL is set + os.environ.pop("CLEVERAGENTS_SERVER_URL", None) + + from cleveragents.cli.commands.server import resolve_server_mode + + mode = resolve_server_mode() + if mode != "disabled": + print(f"FAIL: expected 'disabled', got '{mode}'", file=sys.stderr) + sys.exit(1) + + print("server-mode-ok") + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +_COMMANDS = { + "stub-clients": stub_clients, + "config-validation": config_validation, + "config-keys": config_keys, + "server-mode": server_mode, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/server_stubs.robot b/robot/server_stubs.robot new file mode 100644 index 000000000..68a42c3e3 --- /dev/null +++ b/robot/server_stubs.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Smoke tests for server client stubs and connection config +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_server_stubs.py + +*** Test Cases *** +Server Stub Client NotImplementedError + [Documentation] Verify StubServerClient raises NotImplementedError + ${result}= Run Process ${PYTHON} ${HELPER} stub-clients cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-stub-clients-ok + +Server Connection Config Validation + [Documentation] Verify ServerConnectionConfig validates URLs + ${result}= Run Process ${PYTHON} ${HELPER} config-validation cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-config-validation-ok + +Server Config Keys Registered + [Documentation] Verify server config keys are in the registry + ${result}= Run Process ${PYTHON} ${HELPER} config-keys cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-config-keys-ok + +Server Mode Resolution + [Documentation] Verify resolve_server_mode returns disabled when no URL + ${result}= Run Process ${PYTHON} ${HELPER} server-mode cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-mode-ok + +CLI Server Connect Stub + [Documentation] Verify CLI server connect prints stub warning + ${result}= Run Process ${PYTHON} -m cleveragents server connect https://stub.example.com --format json cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} not yet implemented diff --git a/src/cleveragents/acp/__init__.py b/src/cleveragents/acp/__init__.py index 65cceab8f..fbe58a95d 100644 --- a/src/cleveragents/acp/__init__.py +++ b/src/cleveragents/acp/__init__.py @@ -10,10 +10,22 @@ no network, no authentication. In **server mode** the :class:`AcpHttpTransport` is a stub that raises :class:`AcpNotAvailableError` for every operation. When server mode is implemented the concrete transport will replace these stubs. + +Server client protocols (:class:`ServerClient`, :class:`RemoteExecutionClient`, +:class:`AuthClient`) and their stub implementations are provided for forward +compatibility. :class:`ServerConnectionConfig` validates connection parameters. """ from __future__ import annotations +from cleveragents.acp.clients import ( + AuthClient, + RemoteExecutionClient, + ServerClient, + StubAuthClient, + StubRemoteExecutionClient, + StubServerClient, +) from cleveragents.acp.errors import ( AcpError, AcpNotAvailableError, @@ -29,6 +41,7 @@ from cleveragents.acp.models import ( AcpResponse, AcpVersion, ) +from cleveragents.acp.server_config import ServerConnectionConfig from cleveragents.acp.transport import AcpHttpTransport from cleveragents.acp.versioning import AcpVersionNegotiator @@ -46,4 +59,11 @@ __all__ = [ "AcpVersion", "AcpVersionMismatchError", "AcpVersionNegotiator", + "AuthClient", + "RemoteExecutionClient", + "ServerClient", + "ServerConnectionConfig", + "StubAuthClient", + "StubRemoteExecutionClient", + "StubServerClient", ] diff --git a/src/cleveragents/acp/clients.py b/src/cleveragents/acp/clients.py new file mode 100644 index 000000000..d6a02bdaf --- /dev/null +++ b/src/cleveragents/acp/clients.py @@ -0,0 +1,197 @@ +"""Server client protocol stubs for remote server communication. + +Defines three Protocol interfaces (``ServerClient``, ``RemoteExecutionClient``, +``AuthClient``) and their stub implementations that raise +:class:`NotImplementedError`. When server mode is implemented as a separate +project the concrete clients will replace these stubs. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +# --------------------------------------------------------------------------- +# Protocol definitions +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ServerClient(Protocol): + """Protocol for server health and version negotiation.""" + + def health_check(self) -> bool: + """Check server health. + + Returns: + ``True`` when the server is healthy and reachable. + """ + ... + + def get_version(self) -> str: + """Get server version string. + + Returns: + The semantic version reported by the server. + """ + ... + + +@runtime_checkable +class RemoteExecutionClient(Protocol): + """Protocol for remote plan execution.""" + + def execute_plan(self, plan_id: str) -> dict[str, Any]: + """Submit a plan for remote execution. + + Args: + plan_id: Identifier of the plan to execute. + + Returns: + Execution metadata including status and any server-assigned ID. + """ + ... + + def get_plan_status(self, plan_id: str) -> dict[str, Any]: + """Query the status of a remotely-executing plan. + + Args: + plan_id: Identifier of the plan. + + Returns: + Status metadata including phase, progress, and elapsed time. + """ + ... + + def cancel_plan(self, plan_id: str) -> bool: + """Request cancellation of a remotely-executing plan. + + Args: + plan_id: Identifier of the plan to cancel. + + Returns: + ``True`` if the cancellation request was accepted. + """ + ... + + +@runtime_checkable +class AuthClient(Protocol): + """Protocol for authentication with the server.""" + + def authenticate(self, token: str) -> bool: + """Authenticate with the server using a bearer token. + + Args: + token: Authentication token. + + Returns: + ``True`` when authentication succeeds. + """ + ... + + def validate_token(self, token: str) -> bool: + """Validate that a token is still valid without a full auth flow. + + Args: + token: Token to validate. + + Returns: + ``True`` when the token is valid and not expired. + """ + ... + + +# --------------------------------------------------------------------------- +# Stub implementations — raise NotImplementedError for all operations +# --------------------------------------------------------------------------- + +_STUB_MSG = "Server client is not yet implemented" + + +class StubServerClient: + """Stub implementation of :class:`ServerClient`. + + All methods raise :class:`NotImplementedError`. + """ + + def health_check(self) -> bool: + """Raise :class:`NotImplementedError`.""" + raise NotImplementedError(_STUB_MSG) + + def get_version(self) -> str: + """Raise :class:`NotImplementedError`.""" + raise NotImplementedError(_STUB_MSG) + + +class StubRemoteExecutionClient: + """Stub implementation of :class:`RemoteExecutionClient`. + + All methods raise :class:`NotImplementedError`. + """ + + def execute_plan(self, plan_id: str) -> dict[str, Any]: + """Raise :class:`NotImplementedError`. + + Args: + plan_id: Identifier of the plan to execute. + """ + if not plan_id or not isinstance(plan_id, str): + raise ValueError("plan_id must be a non-empty string") + raise NotImplementedError(_STUB_MSG) + + def get_plan_status(self, plan_id: str) -> dict[str, Any]: + """Raise :class:`NotImplementedError`. + + Args: + plan_id: Identifier of the plan. + """ + if not plan_id or not isinstance(plan_id, str): + raise ValueError("plan_id must be a non-empty string") + raise NotImplementedError(_STUB_MSG) + + def cancel_plan(self, plan_id: str) -> bool: + """Raise :class:`NotImplementedError`. + + Args: + plan_id: Identifier of the plan to cancel. + """ + if not plan_id or not isinstance(plan_id, str): + raise ValueError("plan_id must be a non-empty string") + raise NotImplementedError(_STUB_MSG) + + +class StubAuthClient: + """Stub implementation of :class:`AuthClient`. + + All methods raise :class:`NotImplementedError`. + """ + + def authenticate(self, token: str) -> bool: + """Raise :class:`NotImplementedError`. + + Args: + token: Authentication token. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + raise NotImplementedError(_STUB_MSG) + + def validate_token(self, token: str) -> bool: + """Raise :class:`NotImplementedError`. + + Args: + token: Token to validate. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + raise NotImplementedError(_STUB_MSG) + + +__all__ = [ + "AuthClient", + "RemoteExecutionClient", + "ServerClient", + "StubAuthClient", + "StubRemoteExecutionClient", + "StubServerClient", +] diff --git a/src/cleveragents/acp/server_config.py b/src/cleveragents/acp/server_config.py new file mode 100644 index 000000000..56f98b3af --- /dev/null +++ b/src/cleveragents/acp/server_config.py @@ -0,0 +1,54 @@ +"""Server connection configuration model. + +Provides :class:`ServerConnectionConfig`, a validated Pydantic model for +server connection parameters. In the current stub implementation the model +is used by the CLI ``connect`` command and ``info`` output but no actual +network calls are made. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, field_validator + + +class ServerConnectionConfig(BaseModel): + """Validated configuration for connecting to a CleverAgents server. + + Attributes: + server_url: Base URL of the server (must start with ``http://`` or + ``https://``). + namespace: Namespace on the server (defaults to ``"default"``). + auth_token_ref: Optional reference to an authentication token + (e.g. environment variable name or secret store key). + tls_verify: Whether to verify TLS certificates (defaults to + ``True``). + """ + + model_config = ConfigDict(frozen=True, strict=False) + + server_url: str + namespace: str = "default" + auth_token_ref: str | None = None + tls_verify: bool = True + + @field_validator("server_url") + @classmethod + def _validate_server_url(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("server_url must not be empty") + stripped = value.strip() + if not stripped.startswith(("http://", "https://")): + raise ValueError("server_url must start with http:// or https://") + return stripped + + @field_validator("namespace") + @classmethod + def _validate_namespace(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("namespace must not be empty") + return value.strip() + + +__all__ = [ + "ServerConnectionConfig", +] diff --git a/src/cleveragents/application/services/config_service.py b/src/cleveragents/application/services/config_service.py index 96efe1c6b..74cd1a818 100644 --- a/src/cleveragents/application/services/config_service.py +++ b/src/cleveragents/application/services/config_service.py @@ -272,6 +272,24 @@ def _build_catalog() -> None: env_var="CLEVERAGENTS_SERVER_SYNC_INTERVAL", description="Seconds between automatic background syncs.", ) + _register( + "server", + "tls-verify", + bool, + True, + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_TLS_VERIFY", + description="Verify TLS certificates when connecting to the server.", + ) + _register( + "server", + "namespace", + str, + "default", + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_NAMESPACE", + description="Default namespace on the remote server.", + ) # ── actor.* (5 keys) ──────────────────────────────────────────────── _register( diff --git a/src/cleveragents/cli/commands/server.py b/src/cleveragents/cli/commands/server.py new file mode 100644 index 000000000..07d85bfe4 --- /dev/null +++ b/src/cleveragents/cli/commands/server.py @@ -0,0 +1,214 @@ +"""Server connection CLI commands. + +Provides the ``agents server connect`` stub command and server-mode +status helpers. All actual server communication is stubbed out — +commands persist configuration and print explicit warnings. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any + +import typer +from pydantic import ValidationError +from rich.console import Console +from rich.panel import Panel + +from cleveragents.acp.server_config import ServerConnectionConfig +from cleveragents.application.services.config_service import ConfigService +from cleveragents.cli.formatting import OutputFormat, format_output + +app: Any = typer.Typer(help="Server connection management (stub).") +console: Console = Console() + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_STUB_WARNING: str = ( + "Server connection is not yet implemented. " + "The URL has been saved to configuration but no connection will be made." +) + + +def _get_config_service() -> ConfigService: + """Return a ``ConfigService`` wired to the standard config paths. + + Paths are computed lazily from ``Path.home()`` so that changes to + ``HOME`` (e.g. in tests) are picked up correctly. + """ + config_dir = Path.home() / ".cleveragents" + config_path = config_dir / "config.toml" + return ConfigService(config_dir=config_dir, config_path=config_path) + + +def resolve_server_mode() -> str: + """Determine the current server mode. + + Returns: + ``"disabled"`` when no server URL is configured, ``"stubbed"`` + when a URL is present but the client is not yet implemented. + """ + svc = _get_config_service() + try: + resolved = svc.resolve("server.url") + if resolved.value is not None and str(resolved.value).strip(): + return "stubbed" + except (ValueError, KeyError): + pass + return "disabled" + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@app.command("connect") +def server_connect( + server_url: Annotated[ + str, + typer.Argument( + help="Server URL to connect to (e.g. https://agents.example.com)" + ), + ], + namespace: Annotated[ + str, + typer.Option("--namespace", "-n", help="Namespace on the server"), + ] = "default", + tls_verify: Annotated[ + bool, + typer.Option("--tls-verify/--no-tls-verify", help="Verify TLS certificates"), + ] = True, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: rich, plain, json, yaml"), + ] = "rich", +) -> None: + """Connect to a CleverAgents server (stub). + + Validates and persists the server URL, namespace, and TLS settings + to the global configuration file. No actual connection is made — + server mode is not yet implemented. + + Examples:: + + agents server connect https://agents.example.com + agents server connect https://agents.example.com --namespace my-team + agents server connect http://localhost:8080 --no-tls-verify + """ + # Validate the connection config + try: + config = ServerConnectionConfig( + server_url=server_url, + namespace=namespace, + tls_verify=tls_verify, + ) + except ValidationError as exc: + console.print(f"[red]Invalid server configuration:[/red] {exc}") + raise typer.Exit(code=1) from exc + + # Persist to config file + svc = _get_config_service() + config_data = svc.read_config() + config_data["server.url"] = config.server_url + config_data["server.namespace"] = config.namespace + config_data["server.tls-verify"] = config.tls_verify + svc.write_config(config_data) + + result: dict[str, Any] = { + "server_url": config.server_url, + "namespace": config.namespace, + "tls_verify": config.tls_verify, + "status": "stubbed", + "warning": _STUB_WARNING, + } + + if fmt != OutputFormat.RICH.value: + typer.echo(format_output(result, fmt)) + return + + console.print( + Panel( + f"[bold]Server URL:[/bold] {config.server_url}\n" + f"[bold]Namespace:[/bold] {config.namespace}\n" + f"[bold]TLS Verify:[/bold] {config.tls_verify}\n" + f"[bold]Status:[/bold] stubbed\n" + f"\n[yellow]{_STUB_WARNING}[/yellow]", + title="Server Connection (Stub)", + expand=False, + ) + ) + + +@app.command("status") +def server_status( + fmt: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: rich, plain, json, yaml"), + ] = "rich", +) -> None: + """Show current server connection status. + + Displays the configured server URL (if any) and whether server mode + is disabled or stubbed. + """ + mode = resolve_server_mode() + svc = _get_config_service() + + server_url: str | None = None + namespace: str = "default" + tls_verify: bool = True + + try: + resolved_url = svc.resolve("server.url") + if resolved_url.value is not None: + server_url = str(resolved_url.value) + except (ValueError, KeyError): + pass + + try: + resolved_ns = svc.resolve("server.namespace") + if resolved_ns.value is not None: + namespace = str(resolved_ns.value) + except (ValueError, KeyError): + pass + + try: + resolved_tls = svc.resolve("server.tls-verify") + if resolved_tls.value is not None: + tls_verify = bool(resolved_tls.value) + except (ValueError, KeyError): + pass + + result: dict[str, Any] = { + "server_mode": mode, + "server_url": server_url, + "namespace": namespace, + "tls_verify": tls_verify, + } + + if fmt != OutputFormat.RICH.value: + typer.echo(format_output(result, fmt)) + return + + url_display = server_url if server_url else "(not configured)" + console.print( + Panel( + f"[bold]Server Mode:[/bold] {mode}\n" + f"[bold]Server URL:[/bold] {url_display}\n" + f"[bold]Namespace:[/bold] {namespace}\n" + f"[bold]TLS Verify:[/bold] {tls_verify}", + title="Server Status", + expand=False, + ) + ) + + +__all__ = [ + "app", + "resolve_server_mode", + "server_connect", + "server_status", +] diff --git a/src/cleveragents/cli/commands/system.py b/src/cleveragents/cli/commands/system.py index 6e153364e..bdf4e29b1 100644 --- a/src/cleveragents/cli/commands/system.py +++ b/src/cleveragents/cli/commands/system.py @@ -125,12 +125,16 @@ def build_info_data() -> dict[str, Any]: else: storage["logs"] = "0 MB" + from cleveragents.cli.commands.server import resolve_server_mode + + server_mode = resolve_server_mode() + return { "version": __version__, "data_dir": str(data_dir), "config_path": str(config_path), "database": db_url, - "server_mode": "disabled", + "server_mode": server_mode, "platform": f"{platform.system()} {platform.release()} ({platform.machine()})", "automation": settings.default_automation_profile, "providers_configured": len(configured_providers), diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index cb1e052f7..213491cdd 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -96,6 +96,7 @@ def _register_subcommands() -> None: ) from cleveragents.cli.commands.auto_debug import app as auto_debug_app from cleveragents.cli.commands.repl import _repl_app + from cleveragents.cli.commands.server import app as server_app except Exception as exc: # pragma: no cover import traceback @@ -188,6 +189,11 @@ def _register_subcommands() -> None: name="repl", help="Start an interactive REPL session", ) + app.add_typer( + server_app, + name="server", + help="Server connection management (stub)", + ) _subcommands_registered = True @@ -595,6 +601,7 @@ def main(args: list[str] | None = None) -> int: "automation-profile", # Automation profile management "invariant", # Invariant constraint management "repl", # Interactive REPL + "server", # Server connection management "tell", # Shortcut for plan tell "build", # Shortcut for plan build "apply", # Shortcut for plan apply diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 0970862bf..c8f5eef73 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -483,3 +483,16 @@ validation_errors # noqa: B018, F821 get_spawn_decisions # noqa: B018, F821 build_spawn_entries # noqa: B018, F821 validate_spawn # noqa: B018, F821 + +# Server client stubs — public API for server mode (#201) +ServerClient # noqa: B018, F821 +RemoteExecutionClient # noqa: B018, F821 +AuthClient # noqa: B018, F821 +StubServerClient # noqa: B018, F821 +StubRemoteExecutionClient # noqa: B018, F821 +StubAuthClient # noqa: B018, F821 +ServerConnectionConfig # noqa: B018, F821 +resolve_server_mode # noqa: B018, F821 +server_connect # noqa: B018, F821 +server_status # noqa: B018, F821 +_STUB_WARNING # noqa: B018, F821