diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7c53dcbe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,136 @@ +# CLAUDE.md - Project Development Context + +This file provides context for AI coding assistants (Claude Code, etc.) working +on the `kbagent` (Keboola Agent CLI) project. + +## Quick Start + +### Build and install (editable mode) + +```bash +uv pip install -e ".[dev]" +``` + +### Run the CLI + +```bash +kbagent --help +kbagent project list +kbagent --json project list +``` + +### Run tests + +```bash +pytest tests/ -v +``` + +Or with uv: + +```bash +uv run pytest tests/ -v +``` + +## Project Structure + +``` +src/keboola_agent_cli/ + __init__.py # Package init, exports __version__ + __main__.py # python -m support + cli.py # Typer root app, global options, subcommand registration + client.py # HTTP client for Keboola Storage API (retry, timeouts) + config_store.py # JSON config persistence (~/.config/keboola-agent-cli/config.json) + errors.py # KeboolaApiError, ConfigError, mask_token() + models.py # Pydantic models: AppConfig, ProjectConfig, TokenVerifyResponse, etc. + output.py # OutputFormatter - dual mode (JSON for agents, Rich for humans) + commands/ + __init__.py + project.py # project add/list/remove/edit/status commands + config.py # config list/detail commands + context.py # Agent usage instructions + doctor.py # Health check command + services/ + __init__.py + project_service.py # Business logic for project management + config_service.py # Business logic for config listing (Phase 3) +tests/ + conftest.py # Shared fixtures (tmp dirs, formatters) + test_cli.py # End-to-end CLI tests via CliRunner + test_client.py # API client tests (mocked HTTP) + test_config_store.py # Config persistence tests + test_errors.py # Error handling and token masking tests + test_models.py # Pydantic model serialization tests + test_output.py # Output formatter tests + test_services.py # Service layer business logic tests +``` + +## Architecture (3-Layer) + +``` +CLI commands --> Services (business logic) --> API Client (HTTP) +(Typer, output) (aggregation, resolving) (endpoints, requests) +``` + +- **API changes** --> only modify `client.py` +- **Business logic changes** --> only modify `services/` +- **UI/output changes** --> only modify `commands/` + +## Coding Conventions + +### Commands (`commands/`) + +- Thin layer: parse arguments with Typer, call service, format output. +- No business logic in commands. +- Use `_get_formatter(ctx)` and `_get_service(ctx)` helpers to pull from Typer context. +- All commands handle `KeboolaApiError` and `ConfigError` with proper exit codes. + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Usage error (bad arguments) | +| 3 | Authentication error (invalid token) | +| 4 | Network error (timeout, unreachable) | +| 5 | Configuration error (bad config file, missing alias) | + +### Services (`services/`) + +- Accept `ConfigStore` and `client_factory` via dependency injection. +- `client_factory` is `Callable[[str, str], KeboolaClient]` for easy mocking. +- Return plain dicts (not Pydantic models) so the CLI layer can format freely. + +### Models (`models.py`) + +- All data contracts are Pydantic v2 models. +- `AppConfig` is the top-level config file schema (versioned). +- `ProjectConfig` stores per-project connection details. +- `SuccessResponse` and `ErrorResponse` define the JSON output envelope. + +### Output (`output.py`) + +- `OutputFormatter` supports dual mode: `--json` for agents, Rich for humans. +- JSON mode writes to stdout via `SuccessResponse` / `ErrorResponse`. +- Human mode uses `rich.console.Console` with optional color disable. + +### Error Handling (`errors.py`) + +- `KeboolaApiError`: HTTP/API failures with `error_code`, `status_code`, `retryable`. +- `ConfigError`: Configuration file issues. +- `mask_token()`: Always mask tokens in output (`901-...pt0k`). + +### Testing + +- Use `pytest` with `typer.testing.CliRunner` for CLI tests. +- Mock `ConfigStore` and `ProjectService` via `unittest.mock.patch`. +- Use `tmp_path` fixture for isolated config directories. +- All API calls in tests must be mocked (no real HTTP). + +### Dependencies + +- **Typer** (with `rich` extra) for CLI framework +- **Rich** for formatted terminal output +- **httpx** for HTTP client +- **Pydantic v2** for data validation and serialization +- **platformdirs** for cross-platform config paths diff --git a/README.md b/README.md index a967cdb6..d38b2a83 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,181 @@ -# Keboola Agent CLI +# kbagent - Keboola Agent CLI -AI-friendly CLI for managing Keboola projects. +AI-friendly command-line interface for managing Keboola projects. Designed for +use by AI coding agents (Claude, Codex, Gemini) and human operators alike. + +## Features + +- **Multi-project management** -- connect to multiple Keboola stacks and projects +- **AI-optimized output** -- structured JSON output with `--json` flag for easy parsing +- **Configuration browsing** -- list and inspect configurations across projects +- **Health diagnostics** -- built-in `doctor` command to verify setup +- **Self-documenting** -- `context` command provides comprehensive usage instructions for AI agents + +## Installation + +### With uv (recommended) + +```bash +uv tool install . +``` + +### With pip + +```bash +pip install . +``` + +### Development install + +```bash +uv pip install -e ".[dev]" +``` + +After installation, the `kbagent` command is available globally. + +## Quick Start + +### 1. Add a project + +```bash +kbagent project add \ + --alias prod \ + --url https://connection.keboola.com \ + --token YOUR_STORAGE_API_TOKEN +``` + +### 2. List connected projects + +```bash +kbagent project list +``` + +### 3. Check connectivity + +```bash +kbagent project status +``` + +### 4. Browse configurations + +```bash +kbagent config list --project prod +``` + +### 5. Run health check + +```bash +kbagent doctor +``` + +## Commands + +### Project Management + +| Command | Description | +|---------|-------------| +| `kbagent project add --alias NAME --url URL --token TOKEN` | Add a new project connection | +| `kbagent project list` | List all connected projects | +| `kbagent project remove --alias NAME` | Remove a project connection | +| `kbagent project edit --alias NAME [--url URL] [--token TOKEN]` | Edit a project | +| `kbagent project status [--project NAME]` | Test connectivity | + +### Configuration Browsing + +| Command | Description | +|---------|-------------| +| `kbagent config list [--project NAME] [--component-type TYPE] [--component-id ID]` | List configurations | +| `kbagent config detail --project NAME --component-id ID --config-id ID` | Show configuration details | + +### Diagnostics + +| Command | Description | +|---------|-------------| +| `kbagent context` | Show AI agent usage instructions | +| `kbagent doctor` | Run health checks | + +### Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--json` | `-j` | Output structured JSON | +| `--verbose` | `-v` | Enable verbose output | +| `--no-color` | | Disable colored output | + +## JSON Output + +All commands support `--json` for structured output. + +**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 (bad arguments) | +| 3 | Authentication error (invalid/expired token) | +| 4 | Network error (timeout, unreachable server) | +| 5 | Configuration error (corrupt config, missing alias) | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `KBC_TOKEN` | Default Storage API token (fallback for `--token`) | +| `KBC_STORAGE_API_URL` | Default Keboola stack URL (fallback for `--url`) | + +## Architecture + +The project follows a 3-layer architecture: + +``` +CLI commands --> Services --> API Client +(commands/) (services/) (client.py) +``` + +- **Commands** -- thin Typer layer, parses arguments, formats output +- **Services** -- business logic, aggregation, validation +- **Client** -- HTTP communication with Keboola Storage API (retry, timeouts) + +Configuration is stored at `~/.config/keboola-agent-cli/config.json` with +`0600` permissions. Tokens are always masked in output. + +## Development + +```bash +# Install in development mode +uv pip install -e ".[dev]" + +# Run tests +pytest tests/ -v + +# Run a specific test file +pytest tests/test_cli.py -v +``` + +## License + +MIT diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 0fd80fa5..7494db09 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1,9 +1,195 @@ -"""Context command - provides usage instructions for AI agents.""" +"""Context command - provides comprehensive usage instructions for AI agents.""" import typer +from .. import __version__ from ..output import OutputFormatter +CONTEXT_TEXT = f"""\ +# kbagent - Keboola Agent CLI v{__version__} + +You are interacting with `kbagent`, an AI-friendly command-line interface for +managing Keboola projects. This tool is designed to be used by AI agents +(Claude, Codex, Gemini, and others) as well as human operators. + +## Key Principle + +Always use the `--json` flag when calling kbagent programmatically. This +ensures structured, machine-parseable output that is easy to process. + +--- + +## Available Commands + +### Project Management + +```bash +# List all connected projects +kbagent --json project list + +# Add a new project connection (verifies token via API) +kbagent --json project add --alias my-project --url https://connection.keboola.com --token YOUR_TOKEN + +# Remove a project connection +kbagent --json project remove --alias my-project + +# Edit a project (update URL, token, or both) +kbagent --json project edit --alias my-project --url https://new.stack.url +kbagent --json project edit --alias my-project --token NEW_TOKEN + +# Check connectivity status of all projects +kbagent --json project status + +# Check connectivity of a specific project +kbagent --json project status --project my-project +``` + +### Configuration Browsing + +```bash +# List all configurations across all projects +kbagent --json config list + +# List configurations from a specific project +kbagent --json config list --project my-project + +# List configurations from multiple projects +kbagent --json config list --project proj-a --project proj-b + +# Filter by component type (extractor, writer, transformation, application) +kbagent --json config list --component-type extractor + +# Filter by specific component ID +kbagent --json config list --component-id keboola.ex-db-snowflake + +# Get full detail of a specific configuration +kbagent --json config detail --project my-project --component-id keboola.ex-db-snowflake --config-id 12345 +``` + +### Diagnostics + +```bash +# Run health checks (config file, connectivity, version) +kbagent --json doctor + +# Show these instructions +kbagent context +``` + +--- + +## Global Flags + +| Flag | Short | Description | +|---------------|-------|------------------------------------------------| +| `--json` | `-j` | Output structured JSON (recommended for agents) | +| `--verbose` | `-v` | Enable verbose output | +| `--no-color` | | Disable colored/Rich output | + +--- + +## JSON Output Format + +### Success Response + +```json +{{ + "status": "ok", + "data": [ ... ] +}} +``` + +### Error Response + +```json +{{ + "status": "error", + "error": {{ + "code": "INVALID_TOKEN", + "message": "Token is invalid or expired", + "project": "my-project", + "retryable": false + }} +}} +``` + +--- + +## Exit Codes + +| Code | Meaning | +|------|--------------------------------------------------| +| 0 | Success | +| 1 | General error | +| 2 | Usage error (bad arguments, missing flags) | +| 3 | Authentication error (invalid/expired token) | +| 4 | Network error (timeout, unreachable server) | +| 5 | Configuration error (corrupted config file, missing alias) | + +--- + +## Common Workflows + +### 1. Set up a new project + +```bash +kbagent --json project add --alias prod --url https://connection.keboola.com --token 901-xxxxx +``` + +Parse the response to confirm the project was added: +```bash +kbagent --json project list +``` + +### 2. Explore configurations + +```bash +# Get all extractors +kbagent --json config list --component-type extractor + +# Get details of a specific config +kbagent --json config detail --project prod --component-id keboola.ex-db-snowflake --config-id 12345 +``` + +### 3. Verify everything is working + +```bash +kbagent --json doctor +``` + +### 4. Multi-project operations + +```bash +# Compare configurations across environments +kbagent --json config list --project prod +kbagent --json config list --project staging +``` + +--- + +## Tips for AI Agents + +1. **Always use `--json`**: Raw JSON is easier to parse than Rich-formatted tables. +2. **Check exit codes**: Non-zero exit codes indicate errors. Use the exit code to determine the type of failure. +3. **Parse the `status` field**: Every JSON response has `"status": "ok"` or `"status": "error"`. +4. **Tokens are masked**: Token values in output are always masked (e.g., `901-...pt0k`). Never attempt to extract full tokens from output. +5. **Error responses include `retryable`**: If `retryable` is `true`, you can safely retry the operation. +6. **Use `kbagent doctor`** to verify the setup before performing operations. +7. **Project aliases are case-sensitive**: Use consistent casing when referring to projects. + +--- + +## Environment Variables + +| Variable | Description | +|-----------------------|--------------------------------------| +| `KBC_TOKEN` | Default Storage API token | +| `KBC_STORAGE_API_URL` | Default Keboola stack URL | + +These can be used as fallbacks when `--token` or `--url` flags are not provided +to `project add`. +""" + def _get_formatter(ctx: typer.Context) -> OutputFormatter: """Retrieve the OutputFormatter from the Typer context.""" @@ -13,4 +199,10 @@ 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)) + + def _human_output(console, data: str) -> None: # type: ignore[no-untyped-def] + from rich.markdown import Markdown + + console.print(Markdown(data)) + + formatter.output(CONTEXT_TEXT, _human_output) diff --git a/src/keboola_agent_cli/commands/doctor.py b/src/keboola_agent_cli/commands/doctor.py index 0fab7769..68b27409 100644 --- a/src/keboola_agent_cli/commands/doctor.py +++ b/src/keboola_agent_cli/commands/doctor.py @@ -1,7 +1,21 @@ -"""Doctor command - health check for CLI configuration and connectivity.""" +"""Doctor command - comprehensive health check for CLI configuration and connectivity.""" + +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 ConfigError, KeboolaApiError, mask_token +from ..models import AppConfig from ..output import OutputFormatter @@ -10,7 +24,192 @@ 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_exists(config_store: ConfigStore) -> dict[str, Any]: + """Check 1: Config file exists and has correct permissions (0600).""" + config_path = config_store.config_path + result: dict[str, Any] = { + "check": "config_exists", + "name": "Config file exists", + "path": str(config_path), + } + + if not config_path.exists(): + result["status"] = "warn" + result["message"] = f"Config file not found at {config_path}. Run 'kbagent project add' to create it." + return result + + # Check permissions (Unix only) + try: + file_stat = os.stat(config_path) + mode = stat.S_IMODE(file_stat.st_mode) + result["permissions"] = oct(mode) + if mode == 0o600: + result["status"] = "ok" + result["message"] = f"Config file exists at {config_path} with correct permissions (0600)." + else: + result["status"] = "warn" + result["message"] = ( + f"Config file exists at {config_path} but has permissions {oct(mode)} " + f"(expected 0o600). Run: chmod 600 {config_path}" + ) + except OSError as exc: + result["status"] = "error" + result["message"] = f"Cannot check file permissions: {exc}" + + return result + + +def _check_config_valid(config_store: ConfigStore) -> tuple[dict[str, Any], AppConfig | None]: + """Check 2: Config file is valid JSON and parseable. + + Returns the check result and the loaded AppConfig (or None on failure). + """ + result: dict[str, Any] = { + "check": "config_valid", + "name": "Config file is valid", + } + + config_path = config_store.config_path + if not config_path.exists(): + result["status"] = "skip" + result["message"] = "Config file does not exist, skipping validation." + return result, None + + try: + config = config_store.load() + num_projects = len(config.projects) + result["status"] = "ok" + result["message"] = f"Config file is valid JSON with {num_projects} project(s) configured." + result["version"] = config.version + result["project_count"] = num_projects + return result, config + except ConfigError as exc: + result["status"] = "error" + result["message"] = f"Config file is invalid: {exc.message}" + return result, None + except json.JSONDecodeError as exc: + result["status"] = "error" + result["message"] = f"Config file contains invalid JSON: {exc}" + return result, None + + +def _check_project_connectivity( + alias: str, + stack_url: str, + token: str, + client_factory: Any = None, +) -> dict[str, Any]: + """Check 3: Verify a project's token via API call with response time.""" + result: dict[str, Any] = { + "check": "project_connectivity", + "name": f"Project '{alias}' connectivity", + "alias": alias, + "stack_url": stack_url, + "token": mask_token(token), + } + + if client_factory is not None: + client = client_factory(stack_url, token) + else: + client = KeboolaClient(stack_url=stack_url, token=token) + + start = time.monotonic() + try: + token_info = client.verify_token() + elapsed = time.monotonic() - start + result["status"] = "ok" + result["response_time_ms"] = round(elapsed * 1000) + result["project_name"] = token_info.project_name + result["project_id"] = token_info.project_id + result["message"] = ( + f"Connected to '{token_info.project_name}' (ID: {token_info.project_id}) " + f"in {result['response_time_ms']}ms." + ) + except KeboolaApiError as exc: + elapsed = time.monotonic() - start + result["status"] = "error" + result["response_time_ms"] = round(elapsed * 1000) + result["error_code"] = exc.error_code + result["message"] = f"Connection failed: {exc.message}" + finally: + client.close() + + return result + + +def _check_cli_version() -> dict[str, Any]: + """Check 4: CLI version information.""" + return { + "check": "cli_version", + "name": "CLI version", + "status": "ok", + "version": __version__, + "message": f"kbagent version {__version__}", + } + + +def _render_human_output(console: Console, checks: list[dict[str, Any]]) -> None: + """Render doctor results as a Rich panel with colored status indicators.""" + table = Table(show_header=True, header_style="bold", expand=True) + table.add_column("Check", style="bold") + table.add_column("Status", justify="center", width=8) + table.add_column("Details") + + status_icons = { + "ok": "[bold green]PASS[/bold green]", + "warn": "[bold yellow]WARN[/bold yellow]", + "error": "[bold red]FAIL[/bold red]", + "skip": "[dim]SKIP[/dim]", + } + + for check in checks: + status = check.get("status", "error") + icon = status_icons.get(status, "[bold red]FAIL[/bold red]") + table.add_row( + check.get("name", check.get("check", "Unknown")), + icon, + check.get("message", ""), + ) + + panel = Panel(table, title="kbagent Doctor", border_style="blue") + 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) + + # Optionally accept a client_factory from context (for testing) + client_factory = ctx.obj.get("client_factory") + + checks: list[dict[str, Any]] = [] + + # Check 1: Config file existence and permissions + checks.append(_check_config_exists(config_store)) + + # Check 2: Config file validity + validity_result, config = _check_config_valid(config_store) + checks.append(validity_result) + + # Check 3: Project connectivity (for each configured project) + if config and config.projects: + for alias, project in config.projects.items(): + checks.append( + _check_project_connectivity( + alias=alias, + stack_url=project.stack_url, + token=project.token, + client_factory=client_factory, + ) + ) + + # Check 4: CLI version + checks.append(_check_cli_version()) + + formatter.output(checks, _render_human_output) diff --git a/tests/test_cli.py b/tests/test_cli.py index e9ce630f..d315c682 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,16 +1,15 @@ -"""Tests for CLI commands via CliRunner - project add, list in JSON and human mode.""" +"""Tests for CLI commands via CliRunner - project add, list, context, doctor, exit codes.""" import json from pathlib import Path from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner 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 TokenVerifyResponse +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.models import AppConfig, ProjectConfig, TokenVerifyResponse from keboola_agent_cli.services.project_service import ProjectService runner = CliRunner() @@ -481,3 +480,541 @@ def test_project_edit_config_error_exit_code_5(self, tmp_path: Path) -> None: assert result.exit_code == 5 output = json.loads(result.output) assert output["status"] == "error" + + +class TestContextCommand: + """Tests for `kbagent context` command.""" + + def test_context_contains_key_phrases(self, tmp_path: Path) -> None: + """context command output contains essential information for AI agents.""" + 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, ["context"]) + + assert result.exit_code == 0 + output = result.output + + # Must mention the tool name + assert "kbagent" in output + + # Must mention --json flag + assert "--json" in output + + # Must mention exit codes + assert "Exit Code" in output or "exit code" in output.lower() + + # Must mention common commands + assert "project add" in output + assert "project list" in output + assert "config list" in output + assert "doctor" in output + + # Must mention workflows + assert "workflow" in output.lower() or "Workflow" in output + + # Must mention JSON output format + assert '"status"' in output or "status" in output + + def test_context_json_mode(self, tmp_path: Path) -> None: + """context --json returns the context text as structured JSON data.""" + 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", "context"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + # The data should contain the context text + assert "kbagent" in output["data"] + assert "--json" in output["data"] + + def test_context_mentions_exit_codes_table(self, tmp_path: Path) -> None: + """context command includes the exit codes table.""" + 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", "context"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + context_text = output["data"] + + # Verify all exit codes are documented + assert "0" in context_text # Success + assert "1" in context_text # General error + assert "2" in context_text # Usage error + assert "3" in context_text # Auth error + assert "4" in context_text # Network error + assert "5" in context_text # Config error + + def test_context_mentions_environment_variables(self, tmp_path: Path) -> None: + """context command documents environment variables.""" + 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", "context"]) + + output = json.loads(result.output) + context_text = output["data"] + assert "KBC_TOKEN" in context_text + assert "KBC_STORAGE_API_URL" in context_text + + +class TestDoctorCommand: + """Tests for `kbagent doctor` command.""" + + def test_doctor_no_config_file(self, tmp_path: Path) -> None: + """doctor with no config file reports warning for missing file.""" + 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, ["doctor"]) + + assert result.exit_code == 0 + # Should mention that config file is not found or similar + assert "not found" in result.output.lower() or "WARN" in result.output or "SKIP" in result.output + + def test_doctor_json_with_config_and_projects(self, tmp_path: Path) -> None: + """doctor --json with config file and projects outputs structured checks.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client(project_name="Prod Project", project_id=5678) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService, \ + patch("keboola_agent_cli.commands.doctor.KeboolaClient", return_value=mock_client): + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + # Add a project first to have something to check + runner.invoke(app, [ + "project", "add", + "--alias", "prod", + "--url", "https://connection.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + + checks = output["data"] + assert isinstance(checks, list) + assert len(checks) >= 3 # config_exists, config_valid, cli_version at minimum + + # Verify check types + check_names = [c["check"] for c in checks] + assert "config_exists" in check_names + assert "config_valid" in check_names + assert "cli_version" in check_names + # With a project configured, should also have connectivity check + assert "project_connectivity" in check_names + + def test_doctor_json_checks_config_permissions(self, tmp_path: Path) -> None: + """doctor --json checks that config file has correct 0600 permissions.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create a config file with correct permissions + store = ConfigStore(config_dir=config_dir) + project = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Test", + project_id=123, + ) + config = AppConfig(projects={"test": project}) + store.save(config) + + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService, \ + patch("keboola_agent_cli.commands.doctor.KeboolaClient", return_value=mock_client): + + MockStore.return_value = store + + service_instance = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"] + + # Find the config_exists check + config_check = next(c for c in checks if c["check"] == "config_exists") + assert config_check["status"] == "ok" + assert "0600" in config_check.get("message", "") or "0o600" in config_check.get("permissions", "") + + def test_doctor_json_checks_project_connectivity(self, tmp_path: Path) -> None: + """doctor --json verifies token connectivity for each project.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create config with a project + store = ConfigStore(config_dir=config_dir) + project = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Prod", + project_id=5678, + ) + config = AppConfig(projects={"prod": project}) + store.save(config) + + mock_client = _make_mock_client(project_name="Prod", project_id=5678) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService, \ + patch("keboola_agent_cli.commands.doctor.KeboolaClient", return_value=mock_client): + + MockStore.return_value = store + MockService.return_value = ProjectService(config_store=store) + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"] + + # Find the connectivity check + conn_checks = [c for c in checks if c["check"] == "project_connectivity"] + assert len(conn_checks) == 1 + conn_check = conn_checks[0] + assert conn_check["alias"] == "prod" + assert conn_check["status"] == "ok" + assert conn_check["project_name"] == "Prod" + assert conn_check["project_id"] == 5678 + assert "response_time_ms" in conn_check + # Token should be masked + assert "10493007" not in conn_check.get("token", "") + + def test_doctor_json_includes_cli_version(self, tmp_path: Path) -> None: + """doctor --json includes CLI version check.""" + 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", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"] + + version_check = next(c for c in checks if c["check"] == "cli_version") + assert version_check["status"] == "ok" + assert "version" in version_check + assert version_check["version"] # non-empty + + def test_doctor_human_mode_shows_panel(self, tmp_path: Path) -> None: + """doctor in human mode shows formatted output with check names.""" + 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, ["doctor"]) + + assert result.exit_code == 0 + # Should include doctor-related output + assert "Doctor" in result.output or "doctor" in result.output.lower() + # Should mention version + assert "version" in result.output.lower() or "Version" in result.output + + def test_doctor_with_invalid_config_json(self, tmp_path: Path) -> None: + """doctor reports error when config file contains invalid JSON.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Write invalid JSON to config file + config_path = config_dir / "config.json" + config_path.write_text("{ invalid json !!!", encoding="utf-8") + config_path.chmod(0o600) + + 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", "doctor"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + checks = output["data"] + + # Config valid check should report error + validity_check = next(c for c in checks if c["check"] == "config_valid") + assert validity_check["status"] == "error" + assert "invalid" in validity_check["message"].lower() or "JSON" in validity_check["message"] + + +class TestNoColorFlag: + """Tests for `--no-color` flag behavior.""" + + def test_no_color_help(self, tmp_path: Path) -> None: + """--no-color flag works with --help.""" + result = runner.invoke(app, ["--no-color", "--help"]) + assert result.exit_code == 0 + assert "kbagent" in result.output.lower() or "Keboola" in result.output + + def test_no_color_project_list(self, tmp_path: Path) -> None: + """--no-color flag works with project list output.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client(project_name="NoCo Project") + + 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 + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "noco", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["--no-color", "project", "list"]) + + assert result.exit_code == 0 + assert "noco" in result.output + + def test_no_color_context(self, tmp_path: Path) -> None: + """--no-color flag works with context 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", "context"]) + + assert result.exit_code == 0 + assert "kbagent" in result.output + + def test_no_color_doctor(self, tmp_path: Path) -> None: + """--no-color flag works with doctor 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", "doctor"]) + + assert result.exit_code == 0 + + +class TestExitCodes: + """Tests for consistent exit codes across commands.""" + + def test_auth_error_exit_code_3(self, tmp_path: Path) -> None: + """Authentication errors return exit code 3 in JSON mode.""" + 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 + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: fail_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "badauth", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 3 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "INVALID_TOKEN" + assert output["error"]["retryable"] is False + + def test_network_error_exit_code_4(self, tmp_path: Path) -> None: + """Network errors return exit code 4 in JSON mode.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + timeout_client = MagicMock() + timeout_client.verify_token.side_effect = KeboolaApiError( + message="Connection timed out", + status_code=0, + error_code="TIMEOUT", + 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 + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: timeout_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "timeout", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 4 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["retryable"] is True + + def test_config_error_exit_code_5(self, tmp_path: Path) -> None: + """Configuration errors return exit code 5 in JSON mode.""" + 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", "doesnotexist", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + + def test_connection_error_exit_code_4(self, tmp_path: Path) -> None: + """Connection errors (not just timeout) also return exit code 4.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + conn_client = MagicMock() + conn_client.verify_token.side_effect = KeboolaApiError( + message="Cannot connect to server", + 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 + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: conn_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "unreachable", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 4