Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check clean hooks
.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks

help: ## Show this help message
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
Expand Down Expand Up @@ -73,12 +73,15 @@ changelog: ## Generate changelog skeleton from GitHub releases
changelog-check: ## Check all releases have changelog entries
uv run python scripts/generate_changelog.py --check

check-error-codes: ## Reject raw error_code string literals in source (use ErrorCode enum)
uv run python scripts/check_error_codes.py

hooks: ## Install git pre-commit hook (lint + format on staged files)
cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."

check: lint format-check skill-check version-check changelog-check test ## Run all checks (lint + format + skill + version + changelog + test)
check: lint format-check skill-check version-check changelog-check check-error-codes test ## Run all checks (lint + format + skill + version + changelog + error-codes + test)

clean: ## Remove build artifacts and caches
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
Expand Down
129 changes: 129 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# kbagent Error Code Reference

All machine-readable codes emitted via `--json` output. Every code is a member
of `ErrorCode` in `src/keboola_agent_cli/errors.py`.

## Versioning

| Change | Version impact |
|---|---|
| Add a new code | Minor bump |
| Rename or remove a code | Major bump |

## Code catalogue

### Auth / access

| Code | Description |
|---|---|
| `INVALID_TOKEN` | Storage API token is invalid or expired |
| `ACCESS_DENIED` | Token lacks the required permission for this API call |
| `PERMISSION_DENIED` | Operation blocked by the active kbagent permission policy |

### Network / transport

| Code | Description |
|---|---|
| `TIMEOUT` | HTTP request timed out |
| `CONNECTION_ERROR` | TCP-level connection failure |
| `RETRY_EXHAUSTED` | All retry attempts failed (typically after 429/5xx) |

### API / generic

| Code | Description |
|---|---|
| `API_ERROR` | Unexpected HTTP error from the Keboola API |
| `NOT_FOUND` | Requested resource does not exist (404) |
| `ALREADY_EXISTS` | Resource or file already exists and was not overwritten |
| `VALIDATION_ERROR` | Request failed API-side validation |
| `INVALID_ARGUMENT` | Caller supplied an invalid argument value |
| `INVALID_FORMAT` | Input is not in the expected format |
| `USAGE_ERROR` | Incorrect CLI flag combination or missing required argument |
| `MISSING_PARAMETER` | A required parameter was not supplied |
| `UNKNOWN_ERROR` | Catch-all for unclassified errors |

### Configuration

| Code | Description |
|---|---|
| `CONFIG_ERROR` | kbagent config problem (e.g. unknown project alias) |
| `NOT_INITIALIZED` | `.keboola/manifest.json` not found; run `sync init` first |
| `INIT_ERROR` | Error during `sync init` auto-init path |

### Jobs

| Code | Description |
|---|---|
| `QUEUE_JOB_FAILED` | Queue API job finished with status `error` or `warning` |
| `QUEUE_JOB_TIMEOUT` | Polling timed out waiting for a Queue job |
| `STORAGE_JOB_FAILED` | Storage API async job finished in a failed state |
| `STORAGE_JOB_TIMEOUT` | Polling timed out waiting for a Storage async job |
| `QUERY_JOB_FAILED` | Query Service job finished in a failed state |
| `QUERY_JOB_TIMEOUT` | Polling timed out waiting for a Query Service job |

### Variables

| Code | Description |
|---|---|
| `NO_VARIABLE_ROWS` | Linked `keboola.variables` config has no rows (fix: `config variables-set`) |
| `MALFORMED_VARIABLES_ROW` | Variables row returned by the API is missing a usable `id` |

### Storage

| Code | Description |
|---|---|
| `UPLOAD_FAILED` | Cloud storage upload to S3/Azure/GCS failed |
| `EXPORT_EMPTY_MANIFEST` | Sliced export manifest contains no slices |
| `EXPORT_NO_FILE` | Export manifest lists no downloadable file |
| `EXPORT_NO_URL` | Export entry has no download URL |
| `NOT_SLICED` | Attempted a sliced-file operation on a non-sliced file |
| `FILE_NO_URL` | File metadata has no usable download URL |

### I/O

| Code | Description |
|---|---|
| `FILE_NOT_FOUND` | Local file path does not exist |
| `DIR_NOT_FOUND` | Local directory path does not exist |
| `READ_ERROR` | Error reading a local file |
| `WRITE_ERROR` | Error writing a local file |
| `INPUT_ERROR` | Invalid or unparseable input data |

### Lineage

| Code | Description |
|---|---|
| `NODE_NOT_FOUND` | Requested node not found in the lineage graph |

### Sharing

| Code | Description |
|---|---|
| `INVALID_SHARING_TYPE` | Unsupported bucket sharing type |
| `NOT_LINKED_BUCKET` | Bucket is not a linked bucket |

### KAI (AI Service)

| Code | Description |
|---|---|
| `KAI_ERROR` | AI Service request failed |
| `KAI_NOT_ENABLED` | KAI is not enabled on this project |

### Workspace / Query

| Code | Description |
|---|---|
| `MISSING_QUERY` | No SQL query was provided |
| `WORKSPACE_NOT_FOUND` | Workspace not found in the project |

### Sync

| Code | Description |
|---|---|
| `PARENT_CONFIG_NOT_TRACKED` | Row operation references a parent config not in the manifest |

### Encryption

| Code | Description |
|---|---|
| `ENCRYPTION_FAILED` | Secret encryption via the Encryption API failed |
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.21.1",
"version": "0.22.0",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.21.1"
version = "0.22.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
78 changes: 78 additions & 0 deletions scripts/check_error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""CI guard: reject raw error_code string literals in source files.

Any site that passes error_code="LITERAL_STRING" to KeboolaApiError,
ConfigError, or formatter.error() must use ErrorCode.<MEMBER> instead.

Usage (run from repo root):
python scripts/check_error_codes.py # exits 1 if violations found
python scripts/check_error_codes.py --list # print all current enum members

Safe exceptions (not flagged):
- tests/ -- string comparisons in assertions are fine
- errors.py -- the enum definition itself
"""

import ast
import sys
from pathlib import Path

SRC_ROOT = Path(__file__).parent.parent / "src"
SKIP_FILES = {"errors.py"}


def _collect_violations(path: Path) -> list[tuple[int, str]]:
"""Return (lineno, code) for each raw error_code string literal."""
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError:
return []

violations = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for kw in node.keywords:
if kw.arg != "error_code":
continue
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
violations.append((kw.value.lineno, kw.value.value))
return violations


def main() -> int:
if "--list" in sys.argv:
# Print all known enum members without importing the package
errors_path = SRC_ROOT / "keboola_agent_cli" / "errors.py"
source = errors_path.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == "ErrorCode":
for item in node.body:
if isinstance(item, ast.Assign):
for t in item.targets:
if isinstance(t, ast.Name):
print(f" ErrorCode.{t.id}")
return 0

found_any = False
for py_file in sorted(SRC_ROOT.rglob("*.py")):
if py_file.name in SKIP_FILES:
continue
violations = _collect_violations(py_file)
if violations:
found_any = True
rel = py_file.relative_to(SRC_ROOT.parent.parent)
for lineno, code in violations:
print(f' {rel}:{lineno}: error_code="{code}" -- use ErrorCode.{code}')

if found_any:
print("\nFAIL: raw error_code string literals found. Replace with ErrorCode.<MEMBER>.")
return 1

print("OK: no raw error_code string literals in source.")
return 0


if __name__ == "__main__":
sys.exit(main())
12 changes: 12 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.22.0": [
"New: ErrorCode enum (StrEnum) in errors.py -- all 46 error codes are now typed constants; "
"every KeboolaApiError / formatter.error() raise site migrated from string literals to "
"ErrorCode.<MEMBER>. Wire format is unchanged (str subtype). CI guard "
"(scripts/check_error_codes.py, wired into 'make check') rejects new raw literals.",
"New: docs/error-codes.md -- versioned reference for all ErrorCode members with "
"add=minor / rename-remove=major semver policy.",
"New: sync init --adopt-existing -- idempotently adopt a .keboola/manifest.json written "
"by the kbc Go CLI (or an older kbagent version) without overwriting it. Validates "
"manifest project_id against the alias token; rejects mismatch with ConfigError (exit 5). "
"Falls through to normal init when no manifest exists. Safe to re-run.",
],
"0.21.1": [
"Fix: sync pull on a newly created dev branch now writes config rows (#193) -- idempotent skip guard for rows was missing a file-existence check, causing rows to be silently skipped when the branch directory was new (hash matched main because the branch is a clone)",
],
Expand Down
4 changes: 2 additions & 2 deletions src/keboola_agent_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from .commands.workspace import workspace_app
from .config_store import ConfigStore, resolve_config_dir
from .constants import EXIT_PERMISSION_DENIED
from .errors import PermissionDeniedError
from .errors import ErrorCode, PermissionDeniedError
from .output import OutputFormatter
from .permissions import PermissionEngine
from .services.branch_service import BranchService
Expand Down Expand Up @@ -277,7 +277,7 @@ def main(
try:
permission_engine.check_or_raise(ctx.invoked_subcommand)
except PermissionDeniedError as exc:
formatter.error(message=exc.message, error_code="PERMISSION_DENIED")
formatter.error(message=exc.message, error_code=ErrorCode.PERMISSION_DENIED)
raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None

# Launch REPL if no subcommand was given (set above)
Expand Down
20 changes: 10 additions & 10 deletions src/keboola_agent_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
STORAGE_JOB_MAX_WAIT,
STORAGE_JOB_POLL_INTERVAL,
)
from .errors import KeboolaApiError
from .errors import ErrorCode, KeboolaApiError
from .http_base import BaseHttpClient
from .models import TokenVerifyResponse

Expand Down Expand Up @@ -523,13 +523,13 @@ def _wait_for_storage_job(
raise KeboolaApiError(
message=error_msg,
status_code=500,
error_code="STORAGE_JOB_FAILED",
error_code=ErrorCode.STORAGE_JOB_FAILED,
retryable=False,
)
raise KeboolaApiError(
message=f"Storage job {job_id} did not complete within {max_wait}s",
status_code=504,
error_code="STORAGE_JOB_TIMEOUT",
error_code=ErrorCode.STORAGE_JOB_TIMEOUT,
retryable=True,
)

Expand Down Expand Up @@ -821,7 +821,7 @@ def share_bucket(
message=f"Invalid sharing type: '{sharing_type}'. "
f"Valid types: {', '.join(endpoint_map.keys())}",
status_code=400,
error_code="INVALID_SHARING_TYPE",
error_code=ErrorCode.INVALID_SHARING_TYPE,
retryable=False,
)

Expand Down Expand Up @@ -1126,7 +1126,7 @@ def _upload_to_cloud(
raise KeboolaApiError(
message=f"Cloud storage upload failed (HTTP {response.status_code})",
status_code=response.status_code,
error_code="UPLOAD_FAILED",
error_code=ErrorCode.UPLOAD_FAILED,
retryable=False,
)

Expand Down Expand Up @@ -1540,7 +1540,7 @@ def _prepare_sliced_download(
raise KeboolaApiError(
message="Sliced file manifest has no entries",
status_code=500,
error_code="EXPORT_EMPTY_MANIFEST",
error_code=ErrorCode.EXPORT_EMPTY_MANIFEST,
retryable=False,
)

Expand Down Expand Up @@ -1819,7 +1819,7 @@ def wait_for_queue_job(
raise KeboolaApiError(
message=f"Queue job {job_id} failed: {error_msg}",
status_code=500,
error_code="QUEUE_JOB_FAILED",
error_code=ErrorCode.QUEUE_JOB_FAILED,
retryable=False,
)
return job
Expand All @@ -1828,7 +1828,7 @@ def wait_for_queue_job(
raise KeboolaApiError(
message=f"Queue job {job_id} did not complete within {max_wait}s",
status_code=504,
error_code="QUEUE_JOB_TIMEOUT",
error_code=ErrorCode.QUEUE_JOB_TIMEOUT,
retryable=True,
)

Expand Down Expand Up @@ -2087,15 +2087,15 @@ def wait_for_query_job(self, query_job_id: str) -> dict[str, Any]:
raise KeboolaApiError(
message=f"Query job failed: {error_msg}",
status_code=500,
error_code="QUERY_JOB_FAILED",
error_code=ErrorCode.QUERY_JOB_FAILED,
retryable=False,
)
time.sleep(QUERY_JOB_POLL_INTERVAL)

raise KeboolaApiError(
message=f"Query job {query_job_id} did not complete within {QUERY_JOB_MAX_WAIT}s",
status_code=504,
error_code="QUERY_JOB_TIMEOUT",
error_code=ErrorCode.QUERY_JOB_TIMEOUT,
retryable=True,
)

Expand Down
Loading
Loading