From 76320aeb56597421d4226f8eca6305007d40ae01 Mon Sep 17 00:00:00 2001 From: Petr Simecek Date: Thu, 26 Feb 2026 13:20:34 +0100 Subject: [PATCH] Phase 4: Agent context, doctor command, CLAUDE.md, README - Replace context.py stub with comprehensive agent instructions including all commands with copy-pasteable examples, tips for AI agents, common workflows, exit codes table, and environment variables - Replace doctor.py stub with full health check: config file existence and permissions (0600), config JSON validity, per-project API connectivity with response time, and CLI version - Create CLAUDE.md with build/run/test instructions, project structure, and coding conventions - Replace minimal README.md with full documentation: installation, quick start, all commands, JSON output format, exit codes, architecture - Add 22 new tests: context output (5), doctor checks (8), --no-color flag (3), exit codes (6) - All 173 tests pass Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 97 ++++ README.md | 201 +++++++- src/keboola_agent_cli/commands/context.py | 147 +++++- src/keboola_agent_cli/commands/doctor.py | 272 ++++++++++- tests/test_cli.py | 550 +++++++++++++++++++++- 5 files changed, 1259 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..970fb599 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md - Development Context for Claude Code + +## Build and Run + +```bash +# Install in development mode (editable) +uv pip install -e ".[dev]" + +# Or install dependencies only +uv sync + +# Run the CLI +kbagent --help +uv run kbagent --help + +# Run a specific command +kbagent --json project list +``` + +## Testing + +```bash +# Run all tests +uv run pytest tests/ -v + +# Run a specific test file +uv run pytest tests/test_cli.py -v + +# Run a specific test class or method +uv run pytest tests/test_cli.py::TestProjectAdd -v +uv run pytest tests/test_cli.py::TestProjectAdd::test_project_add_success_json -v +``` + +## Project Structure + +``` +src/keboola_agent_cli/ + __init__.py # __version__ = "0.1.0" + __main__.py # python -m support + cli.py # Typer root app, global options, subcommand wiring + client.py # LAYER 3: HTTP client (only module talking to Keboola API) + config_store.py # JSON persistence for config.json (0600 permissions) + models.py # Pydantic models shared across layers + output.py # OutputFormatter: JSON vs Rich dual-mode output + errors.py # KeboolaApiError, ConfigError, mask_token() + commands/ + project.py # LAYER 1: CLI commands for project management + config.py # LAYER 1: CLI commands for config browsing + context.py # LAYER 1: Agent usage instructions + doctor.py # LAYER 1: Health check command + services/ + project_service.py # LAYER 2: Business logic for projects + config_service.py # LAYER 2: Business logic for configurations + +tests/ + conftest.py # Shared fixtures (tmp_config_dir, config_store, formatters) + test_cli.py # End-to-end CLI tests via CliRunner + test_client.py # API client tests with mocked HTTP + test_config_store.py # Config persistence tests + test_errors.py # mask_token() tests + test_models.py # Pydantic model tests + test_output.py # OutputFormatter tests + test_services.py # Business logic tests +``` + +## Architecture: 3-Layer Design + +``` +CLI Commands (commands/) --> Services (services/) --> API Client (client.py) + Typer, output Business logic HTTP, endpoints +``` + +- API changes: modify only `client.py` +- Business logic changes: modify only `services/` +- UI changes: modify only `commands/` + +## Coding Conventions + +1. **Typer commands** are thin - they parse arguments, call a service, and format output. No business logic in commands. + +2. **Services** receive `ConfigStore` and a `client_factory` callable via dependency injection. This enables easy testing with mocks. + +3. **All data models** use Pydantic 2.x (`BaseModel`). Models are defined in `models.py` and shared across layers. + +4. **Dual output**: every command supports `--json` for structured output and Rich formatting for human-readable output. Use `OutputFormatter.output(data, human_formatter)`. + +5. **Error handling**: commands catch `KeboolaApiError` and `ConfigError`, map them to the appropriate exit code, and output structured errors in JSON mode. + +6. **Exit codes**: 0=success, 1=general error, 2=usage error, 3=auth error, 4=network error, 5=config error. + +7. **Token masking**: tokens are never printed in full. Use `mask_token()` from `errors.py`. + +8. **Config file**: stored at `~/.config/keboola-agent-cli/config.json` with `0600` permissions. Managed by `ConfigStore`. + +9. **Tests**: use `typer.testing.CliRunner` for CLI tests, `unittest.mock` for mocking services and clients, `pytest` fixtures from `conftest.py`. + +10. **Dependencies**: typer, rich, httpx, pydantic, platformdirs. Dev: pytest, pytest-httpx. diff --git a/README.md b/README.md index a967cdb6..1c4bc98b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,200 @@ -# Keboola Agent CLI +# Keboola Agent CLI (`kbagent`) -AI-friendly CLI for managing Keboola projects. +AI-friendly CLI for managing Keboola projects. Designed for use by AI coding agents (Claude Code, Codex, Gemini) and human developers alike. + +## Features + +- **Multi-project management**: Connect to multiple Keboola projects across different stacks (AWS, Azure, GCP) +- **Configuration browsing**: List and inspect extractors, writers, transformations, and applications +- **Structured JSON output**: Every command supports `--json` for reliable programmatic parsing +- **Health checks**: Built-in `doctor` command to verify setup and connectivity +- **Agent context**: `kbagent context` provides comprehensive usage instructions for AI agents +- **Secure token handling**: Tokens are stored with 0600 permissions and always masked in output + +## Installation + +```bash +# Install with uv (recommended) +uv tool install . + +# Or install in development mode +uv pip install -e ".[dev]" +``` + +After installation, the `kbagent` command is available globally. + +## Quick Start + +```bash +# 1. Add a Keboola project +kbagent project add --alias prod --url https://connection.keboola.com --token YOUR_TOKEN + +# 2. Verify the connection +kbagent project status + +# 3. List configurations +kbagent config list + +# 4. Get structured JSON output (recommended for scripts and agents) +kbagent --json config list +``` + +## Commands + +### Project Management + +```bash +# Add a new project connection (token is verified against API) +kbagent project add --alias NAME --url STACK_URL --token TOKEN + +# List all connected projects +kbagent project list + +# Remove a project connection +kbagent project remove --alias NAME + +# Edit an existing project (re-verifies token if changed) +kbagent project edit --alias NAME [--url NEW_URL] [--token NEW_TOKEN] + +# Check connectivity to all projects (or a specific one) +kbagent project status +kbagent project status --project NAME +``` + +### Configuration Browsing + +```bash +# List all configurations from all projects +kbagent config list + +# Filter by project (can be repeated) +kbagent config list --project prod +kbagent config list --project prod --project dev + +# Filter by component type +kbagent config list --component-type extractor + +# Filter by specific component +kbagent config list --component-id keboola.ex-db-snowflake + +# Get full detail of a specific configuration +kbagent config detail --project prod --component-id keboola.ex-db-snowflake --config-id 12345 +``` + +### Utility Commands + +```bash +# Show usage instructions for AI agents +kbagent context + +# Run health checks (config, permissions, connectivity, version) +kbagent doctor +kbagent --json doctor +``` + +### Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--json` | `-j` | Output in JSON format for programmatic consumption | +| `--verbose` | `-v` | Enable verbose output | +| `--no-color` | | Disable colored Rich formatting | + +Non-TTY environments automatically disable Rich formatting. + +## JSON Output Format + +All commands with `--json` return a consistent structure. + +**Success:** +```json +{ + "status": "ok", + "data": { ... } +} +``` + +**Error:** +```json +{ + "status": "error", + "error": { + "code": "INVALID_TOKEN", + "message": "Token is invalid or expired", + "project": "prod", + "retryable": false + } +} +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Usage error (invalid arguments) | +| 3 | Authentication error (invalid/expired token) | +| 4 | Network error (timeout, unreachable server) | +| 5 | Configuration error (corrupt config, unknown alias) | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `KBC_TOKEN` | Default Storage API token (used by `project add`) | +| `KBC_STORAGE_API_URL` | Default stack URL (used by `project add`) | + +## Configuration + +Configuration is stored at `~/.config/keboola-agent-cli/config.json` with file permissions `0600` to protect stored tokens. + +```json +{ + "version": 1, + "default_project": "prod", + "projects": { + "prod": { + "stack_url": "https://connection.keboola.com", + "token": "901-...", + "project_name": "My Project", + "project_id": 1234 + } + } +} +``` + +## Architecture + +The project follows a 3-layer architecture: + +``` +CLI Commands (commands/) --> Services (services/) --> API Client (client.py) +``` + +- **Commands layer**: Thin Typer wrappers that parse arguments, call services, and format output +- **Services layer**: Business logic, project resolution, multi-project aggregation +- **Client layer**: HTTP communication with Keboola API, retry logic, error mapping + +## Development + +```bash +# Install in development mode +uv pip install -e ".[dev]" + +# Run tests +uv run pytest tests/ -v + +# Run the CLI +uv run kbagent --help +``` + +## Supported Keboola Stacks + +- AWS: `https://connection.keboola.com` +- Azure (North Europe): `https://connection.north-europe.azure.keboola.com` +- GCP (Europe West): `https://connection.europe-west3.gcp.keboola.com` + +## License + +MIT diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 0fd80fa5..2ff3380f 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1,9 +1,143 @@ -"""Context command - provides usage instructions for AI agents.""" +"""Context command - provides comprehensive usage instructions for AI agents. + +Outputs a curated text block that any AI agent (Claude, Codex, Gemini, etc.) +can consume to understand how to use kbagent effectively. +""" import typer +from .. import __version__ from ..output import OutputFormatter +AGENT_CONTEXT = f"""\ +# kbagent - Keboola Agent CLI v{__version__} + +## What is kbagent? + +kbagent is an AI-friendly CLI for managing Keboola projects. It allows you to: +- Connect to multiple Keboola projects across different stacks +- List and inspect configurations (extractors, writers, transformations, applications) +- Check connectivity and health of project connections +- Get structured JSON output suitable for programmatic consumption + +## Quick Start + +1. Add a project connection: + kbagent project add --alias my-project --url https://connection.keboola.com --token YOUR_TOKEN + +2. List connected projects: + kbagent project list + +3. List configurations: + kbagent config list + +## All Commands + +### Project Management + + kbagent project add --alias NAME --url STACK_URL --token TOKEN + Add a new Keboola project connection. The token is verified against the API. + Example: + kbagent --json project add --alias prod --url https://connection.keboola.com --token 901-xxxxx + + kbagent project list + List all connected projects with their details (tokens are always masked). + Example: + kbagent --json project list + + kbagent project remove --alias NAME + Remove a project connection. + Example: + kbagent --json project remove --alias prod + + kbagent project edit --alias NAME [--url NEW_URL] [--token NEW_TOKEN] + Edit an existing project. If token changes, it is re-verified via API. + Example: + kbagent --json project edit --alias prod --url https://connection.north-europe.azure.keboola.com + + kbagent project status [--project NAME] + Test connectivity to projects. Shows OK/ERROR with response time. + Example: + kbagent --json project status + kbagent --json project status --project prod + +### Configuration Browsing + + kbagent config list [--project NAME] [--component-type TYPE] [--component-id ID] + List configurations from one, many, or all connected projects. + --project can be repeated to query multiple projects. + --component-type: extractor, writer, transformation, application + --component-id: specific component (e.g. keboola.ex-db-snowflake) + Examples: + kbagent --json config list + kbagent --json config list --project prod + kbagent --json config list --project prod --project dev + kbagent --json config list --component-type extractor + kbagent --json config list --component-id keboola.ex-db-snowflake + + kbagent config detail --project NAME --component-id ID --config-id ID + Show full detail of a specific configuration including parameters and rows. + Example: + kbagent --json config detail --project prod --component-id keboola.ex-db-snowflake --config-id 12345 + +### Utility Commands + + kbagent context + Show this help text with usage instructions for AI agents. + + kbagent doctor + Run health checks: config file, permissions, connectivity, CLI version. + Example: + kbagent --json doctor + +## Global Flags + + --json / -j Output in JSON format (always use this for programmatic parsing) + --verbose / -v Enable verbose output + --no-color Disable colored output (auto-disabled in non-TTY environments) + +## Tips for AI Agents + +1. ALWAYS use --json flag for reliable, parseable output: + kbagent --json project list + kbagent --json config list + +2. JSON success response format: + {{"status": "ok", "data": ...}} + +3. JSON error response format: + {{"status": "error", "error": {{"code": "ERROR_CODE", "message": "...", "retryable": true/false}}}} + +4. Check the "retryable" field in errors - if true, you can retry the operation. + +5. Tokens are always masked in output (e.g. 901-...pt0k) - this is expected behavior. + +6. Common workflow - explore a project: + kbagent --json project list # See all projects + kbagent --json config list --project prod # List all configs + kbagent --json config list --project prod --component-type extractor # Filter by type + kbagent --json config detail --project prod --component-id keboola.ex-db-snowflake --config-id 12345 + +7. Common workflow - check health: + kbagent --json doctor # Full health check + kbagent --json project status # Test all connections + +8. Environment variables: + KBC_TOKEN - Default Storage API token + KBC_STORAGE_API_URL - Default stack URL + +## Exit Codes + + 0 Success + 1 General error + 2 Usage error (invalid arguments) + 3 Authentication error (invalid or expired token) + 4 Network error (timeout, unreachable server) + 5 Configuration error (corrupt config file, missing project alias) + +When you receive a non-zero exit code, use --json to get structured error details. +""" + def _get_formatter(ctx: typer.Context) -> OutputFormatter: """Retrieve the OutputFormatter from the Typer context.""" @@ -13,4 +147,13 @@ def _get_formatter(ctx: typer.Context) -> OutputFormatter: def context_command(ctx: typer.Context) -> None: """Show usage instructions for AI agents interacting with Keboola.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + + if formatter.json_mode: + # In JSON mode, output the context text as structured data + data = { + "version": __version__, + "context": AGENT_CONTEXT, + } + formatter.output(data) + else: + formatter.console.print(AGENT_CONTEXT) diff --git a/src/keboola_agent_cli/commands/doctor.py b/src/keboola_agent_cli/commands/doctor.py index 0fab7769..3510894d 100644 --- a/src/keboola_agent_cli/commands/doctor.py +++ b/src/keboola_agent_cli/commands/doctor.py @@ -1,8 +1,30 @@ -"""Doctor command - health check for CLI configuration and connectivity.""" +"""Doctor command - comprehensive health check for CLI configuration and connectivity. + +Runs four checks: +1. Config file existence and permissions (0600) +2. Config file valid JSON and parseable +3. Token verification for each project (API call with response time) +4. CLI version +""" + +import json +import os +import stat +import time +from typing import Any import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from .. import __version__ +from ..client import KeboolaClient +from ..config_store import ConfigStore +from ..errors import KeboolaApiError, mask_token +from ..models import AppConfig from ..output import OutputFormatter +from ..services.project_service import ClientFactory, default_client_factory def _get_formatter(ctx: typer.Context) -> OutputFormatter: @@ -10,7 +32,253 @@ def _get_formatter(ctx: typer.Context) -> OutputFormatter: return ctx.obj["formatter"] +def _get_config_store(ctx: typer.Context) -> ConfigStore: + """Retrieve the ConfigStore from the Typer context.""" + return ctx.obj["config_store"] + + +def _check_config_file(config_store: ConfigStore) -> dict[str, Any]: + """Check 1: Config file exists and has correct permissions (0600). + + Returns: + Dict with check name, status (pass/fail/warn), and message. + """ + config_path = config_store.config_path + + if not config_path.exists(): + return { + "check": "config_file", + "name": "Config file", + "status": "warn", + "message": f"Config file not found at {config_path}. Run 'kbagent project add' to create it.", + } + + # Check permissions (Unix only) + try: + file_stat = os.stat(config_path) + mode = stat.S_IMODE(file_stat.st_mode) + if mode != 0o600: + return { + "check": "config_file", + "name": "Config file", + "status": "warn", + "message": f"Config file exists at {config_path} but has permissions {oct(mode)} (expected 0o600).", + } + except OSError: + # On platforms where permission checking is not reliable + pass + + return { + "check": "config_file", + "name": "Config file", + "status": "pass", + "message": f"Config file exists at {config_path} with correct permissions.", + } + + +def _check_config_valid(config_store: ConfigStore) -> tuple[dict[str, Any], AppConfig | None]: + """Check 2: Config file is valid JSON and parseable. + + Returns: + Tuple of (check result dict, parsed AppConfig or None on failure). + """ + config_path = config_store.config_path + + if not config_path.exists(): + return { + "check": "config_valid", + "name": "Config parseable", + "status": "skip", + "message": "No config file to validate.", + }, None + + try: + raw = config_path.read_text(encoding="utf-8") + except OSError as exc: + return { + "check": "config_valid", + "name": "Config parseable", + "status": "fail", + "message": f"Cannot read config file: {exc}", + }, None + + try: + json.loads(raw) + except json.JSONDecodeError as exc: + return { + "check": "config_valid", + "name": "Config parseable", + "status": "fail", + "message": f"Config file is not valid JSON: {exc}", + }, None + + try: + config = config_store.load() + except Exception as exc: + return { + "check": "config_valid", + "name": "Config parseable", + "status": "fail", + "message": f"Config file has invalid structure: {exc}", + }, None + + project_count = len(config.projects) + return { + "check": "config_valid", + "name": "Config parseable", + "status": "pass", + "message": f"Config file is valid JSON with {project_count} project(s).", + }, config + + +def _check_connectivity( + config: AppConfig | None, + client_factory: ClientFactory, +) -> list[dict[str, Any]]: + """Check 3: For each project, verify token via API call. + + Returns: + List of check result dicts, one per project. + """ + if config is None or not config.projects: + return [{ + "check": "connectivity", + "name": "Project connectivity", + "status": "skip", + "message": "No projects configured.", + }] + + results = [] + for alias, project in config.projects.items(): + client = client_factory(project.stack_url, project.token) + start_time = time.monotonic() + try: + token_info = client.verify_token() + elapsed = time.monotonic() - start_time + results.append({ + "check": "connectivity", + "name": f"Project '{alias}'", + "status": "pass", + "message": ( + f"Connected to {project.stack_url} " + f"(project: {token_info.project_name}, id: {token_info.project_id}) " + f"in {round(elapsed * 1000)}ms" + ), + "alias": alias, + "response_time_ms": round(elapsed * 1000), + }) + except KeboolaApiError as exc: + elapsed = time.monotonic() - start_time + results.append({ + "check": "connectivity", + "name": f"Project '{alias}'", + "status": "fail", + "message": f"Failed: {exc.message}", + "alias": alias, + "error_code": exc.error_code, + "response_time_ms": round(elapsed * 1000), + }) + finally: + client.close() + + return results + + +def _check_version() -> dict[str, Any]: + """Check 4: CLI version information. + + Returns: + Check result dict with the current CLI version. + """ + return { + "check": "version", + "name": "CLI version", + "status": "pass", + "message": f"kbagent v{__version__}", + } + + +def _format_doctor_human(console: Console, data: dict[str, Any]) -> None: + """Render doctor check results as a Rich panel with colored status indicators.""" + checks = data.get("checks", []) + + lines = [] + for check in checks: + status = check["status"] + if status == "pass": + icon = "[bold green]PASS[/bold green]" + elif status == "fail": + icon = "[bold red]FAIL[/bold red]" + elif status == "warn": + icon = "[bold yellow]WARN[/bold yellow]" + else: + icon = "[dim]SKIP[/dim]" + + lines.append(f" {icon} {check['name']}: {check['message']}") + + summary = data.get("summary", {}) + total = summary.get("total", 0) + passed = summary.get("passed", 0) + failed = summary.get("failed", 0) + warnings = summary.get("warnings", 0) + + lines.append("") + summary_parts = [f"{total} checks"] + if passed: + summary_parts.append(f"[green]{passed} passed[/green]") + if failed: + summary_parts.append(f"[red]{failed} failed[/red]") + if warnings: + summary_parts.append(f"[yellow]{warnings} warnings[/yellow]") + lines.append(f" Summary: {', '.join(summary_parts)}") + + panel = Panel("\n".join(lines), title="kbagent doctor", expand=False) + console.print(panel) + + def doctor_command(ctx: typer.Context) -> None: """Run health checks on CLI configuration and project connectivity.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + config_store = _get_config_store(ctx) + + # Determine client factory - use the default unless we're in a test context + client_factory: ClientFactory = ctx.obj.get("client_factory", default_client_factory) + + all_checks: list[dict[str, Any]] = [] + + # Check 1: Config file exists with correct permissions + file_check = _check_config_file(config_store) + all_checks.append(file_check) + + # Check 2: Config file is valid JSON and parseable + valid_check, config = _check_config_valid(config_store) + all_checks.append(valid_check) + + # Check 3: Project connectivity + connectivity_checks = _check_connectivity(config, client_factory) + all_checks.extend(connectivity_checks) + + # Check 4: CLI version + version_check = _check_version() + all_checks.append(version_check) + + # Build summary + total = len(all_checks) + passed = sum(1 for c in all_checks if c["status"] == "pass") + failed = sum(1 for c in all_checks if c["status"] == "fail") + warnings = sum(1 for c in all_checks if c["status"] == "warn") + skipped = sum(1 for c in all_checks if c["status"] == "skip") + + result = { + "checks": all_checks, + "summary": { + "total": total, + "passed": passed, + "failed": failed, + "warnings": warnings, + "skipped": skipped, + "healthy": failed == 0, + }, + } + + formatter.output(result, _format_doctor_human) diff --git a/tests/test_cli.py b/tests/test_cli.py index 80247931..5b461fdc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,8 @@ -"""Tests for CLI commands via CliRunner - project and config commands.""" +"""Tests for CLI commands via CliRunner - project, config, context, doctor commands.""" import json +import os +import stat from pathlib import Path from unittest.mock import MagicMock, patch @@ -10,7 +12,7 @@ from keboola_agent_cli.cli import app from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ConfigError, KeboolaApiError -from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.models import AppConfig, ProjectConfig, TokenVerifyResponse from keboola_agent_cli.services.config_service import ConfigService from keboola_agent_cli.services.project_service import ProjectService @@ -1185,3 +1187,547 @@ def test_config_detail_auth_error_exit_code_3(self, tmp_path: Path) -> None: output = json.loads(result.output) assert output["status"] == "error" assert output["error"]["code"] == "INVALID_TOKEN" + + +# --------------------------------------------------------------------------- +# Context command tests +# --------------------------------------------------------------------------- + + +class TestContext: + """Tests for `kbagent context` command.""" + + def test_context_output_contains_key_phrases(self, tmp_path: Path) -> None: + """context command output contains essential phrases for agents.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["context"]) + + assert result.exit_code == 0 + assert "kbagent" in result.output + assert "--json" in result.output + assert "Exit Codes" in result.output + assert "project add" in result.output + assert "config list" in result.output + assert "Tips for AI Agents" in result.output + + def test_context_json_output(self, tmp_path: Path) -> None: + """context --json returns structured JSON with context text.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--json", "context"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert "context" in output["data"] + assert "kbagent" in output["data"]["context"] + assert "--json" in output["data"]["context"] + assert "version" in output["data"] + + def test_context_mentions_all_commands(self, tmp_path: Path) -> None: + """context output mentions all available commands.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["context"]) + + assert result.exit_code == 0 + # All major commands should be mentioned + assert "project add" in result.output + assert "project list" in result.output + assert "project remove" in result.output + assert "project edit" in result.output + assert "project status" in result.output + assert "config list" in result.output + assert "config detail" in result.output + assert "context" in result.output + assert "doctor" in result.output + + def test_context_mentions_exit_codes(self, tmp_path: Path) -> None: + """context output includes exit codes table.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["context"]) + + assert result.exit_code == 0 + assert "Authentication error" in result.output + assert "Network error" in result.output + assert "Configuration error" in result.output + + def test_context_mentions_workflows(self, tmp_path: Path) -> None: + """context output includes common workflows.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["context"]) + + assert result.exit_code == 0 + assert "Common workflow" in result.output + assert "Environment variables" in result.output + + +# --------------------------------------------------------------------------- +# Doctor command tests +# --------------------------------------------------------------------------- + + +class TestDoctor: + """Tests for `kbagent doctor` command.""" + + def test_doctor_no_config_file(self, tmp_path: Path) -> None: + """doctor with no config file shows warning for config and skip for parsing.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + checks = output["data"]["checks"] + assert len(checks) >= 3 + + # Config file check should be warn (not found) + config_check = next(c for c in checks if c["check"] == "config_file") + assert config_check["status"] == "warn" + + # Config valid check should be skip + valid_check = next(c for c in checks if c["check"] == "config_valid") + assert valid_check["status"] == "skip" + + # Version check should pass + version_check = next(c for c in checks if c["check"] == "version") + assert version_check["status"] == "pass" + + def test_doctor_with_valid_config(self, tmp_path: Path) -> None: + """doctor with a valid config file shows pass for file and valid checks.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = ConfigStore(config_dir=config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Test", + project_id=1234, + )) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = store + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"]["checks"] + + # Config file check should pass (file exists with 0600) + config_check = next(c for c in checks if c["check"] == "config_file") + assert config_check["status"] == "pass" + + # Config valid check should pass + valid_check = next(c for c in checks if c["check"] == "config_valid") + assert valid_check["status"] == "pass" + assert "1 project" in valid_check["message"] + + def test_doctor_json_structure(self, tmp_path: Path) -> None: + """doctor --json returns proper structure with checks and summary.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + + data = output["data"] + assert "checks" in data + assert "summary" in data + assert "total" in data["summary"] + assert "passed" in data["summary"] + assert "failed" in data["summary"] + assert "warnings" in data["summary"] + assert "healthy" in data["summary"] + + def test_doctor_human_output(self, tmp_path: Path) -> None: + """doctor in human mode shows a Rich panel with check results.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["doctor"]) + + assert result.exit_code == 0 + assert "kbagent doctor" in result.output + assert "WARN" in result.output or "PASS" in result.output or "SKIP" in result.output + + def test_doctor_connectivity_with_mock_client(self, tmp_path: Path) -> None: + """doctor checks connectivity to projects using the client factory.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = ConfigStore(config_dir=config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Prod", + project_id=1234, + )) + + mock_client = _make_mock_client(project_name="Prod", project_id=1234) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory: + + MockStore.return_value = store + MockFactory.return_value = mock_client + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"]["checks"] + + connectivity_checks = [c for c in checks if c["check"] == "connectivity"] + assert len(connectivity_checks) == 1 + assert connectivity_checks[0]["status"] == "pass" + assert "Prod" in connectivity_checks[0]["message"] + + def test_doctor_connectivity_failure(self, tmp_path: Path) -> None: + """doctor shows fail for projects with connectivity issues.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = ConfigStore(config_dir=config_dir) + store.add_project("bad", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-badtoken-abcdefghijklmn", + project_name="Bad", + project_id=9999, + )) + + fail_client = MagicMock() + fail_client.verify_token.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.commands.doctor.default_client_factory") as MockFactory: + + MockStore.return_value = store + MockFactory.return_value = fail_client + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"]["checks"] + + connectivity_checks = [c for c in checks if c["check"] == "connectivity"] + assert len(connectivity_checks) == 1 + assert connectivity_checks[0]["status"] == "fail" + assert "Invalid token" in connectivity_checks[0]["message"] + + def test_doctor_version_check(self, tmp_path: Path) -> None: + """doctor always includes a version check that passes.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"]["checks"] + + version_check = next(c for c in checks if c["check"] == "version") + assert version_check["status"] == "pass" + assert "kbagent v" in version_check["message"] + + def test_doctor_invalid_json_config(self, tmp_path: Path) -> None: + """doctor reports fail when config file contains invalid JSON.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + config_path = config_dir / "config.json" + config_path.write_text("not valid json {{{", encoding="utf-8") + config_path.chmod(0o600) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"]["checks"] + + valid_check = next(c for c in checks if c["check"] == "config_valid") + assert valid_check["status"] == "fail" + assert "not valid JSON" in valid_check["message"] + + +# --------------------------------------------------------------------------- +# --no-color flag tests +# --------------------------------------------------------------------------- + + +class TestNoColor: + """Tests for --no-color global flag.""" + + def test_no_color_flag_accepted(self, tmp_path: Path) -> None: + """--no-color flag is accepted without error.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--no-color", "context"]) + + assert result.exit_code == 0 + assert "kbagent" in result.output + + def test_no_color_project_list(self, tmp_path: Path) -> None: + """--no-color works with project list command.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService(config_store=store_instance) + + result = runner.invoke(app, ["--no-color", "project", "list"]) + + assert result.exit_code == 0 + + def test_no_color_doctor(self, tmp_path: Path) -> None: + """--no-color works with doctor command.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore: + MockStore.return_value = ConfigStore(config_dir=config_dir) + result = runner.invoke(app, ["--no-color", "doctor"]) + + assert result.exit_code == 0 + assert "kbagent doctor" in result.output + + +# --------------------------------------------------------------------------- +# Exit code tests +# --------------------------------------------------------------------------- + + +class TestExitCodes: + """Tests for consistent exit codes across commands.""" + + def test_auth_error_exit_code_3(self, tmp_path: Path) -> None: + """Authentication error returns exit code 3.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + fail_client = MagicMock() + fail_client.verify_token.side_effect = KeboolaApiError( + message="Invalid or expired token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: fail_client, + ) + + result = runner.invoke(app, [ + "--json", "project", "add", + "--alias", "bad", + "--token", "invalid-token-abcdefgh", + ]) + + assert result.exit_code == 3 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "INVALID_TOKEN" + + def test_network_error_exit_code_4(self, tmp_path: Path) -> None: + """Network error returns exit code 4.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + fail_client = MagicMock() + fail_client.verify_token.side_effect = KeboolaApiError( + message="Connection refused", + status_code=0, + error_code="CONNECTION_ERROR", + retryable=True, + ) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: fail_client, + ) + + result = runner.invoke(app, [ + "--json", "project", "add", + "--alias", "unreachable", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 4 + output = json.loads(result.output) + assert output["status"] == "error" + + def test_config_error_exit_code_5(self, tmp_path: Path) -> None: + """Configuration error returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService(config_store=store_instance) + + result = runner.invoke(app, [ + "--json", "project", "remove", + "--alias", "nonexistent", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + + def test_config_error_exit_code_5_config_detail(self, tmp_path: Path) -> None: + """Configuration error on config detail returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test(config_dir) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "nonexistent", + "--component-id", "test", + "--config-id", "123", + ]) + + assert result.exit_code == 5 + + def test_auth_error_exit_code_3_config_detail(self, tmp_path: Path) -> None: + """Auth error on config detail returns exit code 3.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "prod", + "--component-id", "test", + "--config-id", "123", + ]) + + assert result.exit_code == 3 + + def test_network_error_exit_code_4_config_detail(self, tmp_path: Path) -> None: + """Network error on config detail returns exit code 4.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Request timed out", + status_code=0, + error_code="TIMEOUT", + retryable=True, + ) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "prod", + "--component-id", "test", + "--config-id", "123", + ]) + + assert result.exit_code == 4