# Contributing to kbagent

Guidelines for anyone contributing to this project -- human or AI agent.
Read this **before** writing code. It will save review rounds.

## Coding Style

### Python conventions

- **Python 3.12+** (`pyproject.toml` pins `requires-python = ">=3.12"`) -- use modern syntax (`str | None`, not `Optional[str]`)
- **Type hints** on all function signatures
- **f-strings** for string formatting (no `.format()` or `%`)
- **`pathlib.Path`** over `os.path` -- consistently used throughout the project
- **`httpx`** over `requests` for HTTP calls
- **PEP 8 naming**: `snake_case` for functions/variables, `PascalCase` for classes
- **Pydantic 2.x** (`BaseModel`) for all data models -- defined in `models.py`
- **Specific exception handling** -- never bare `except:`
- **`logging`** module for production logging, not `print()`
- Code is formatted with **ruff** -- run `make format` before committing

### 3-Layer architecture -- respect the boundaries

```
CLI Commands (commands/)  -->  Services (services/)  -->  API Client (client/, manage_client.py)
  Typer, output                 Business logic             HTTP, endpoints
```

| Layer | What goes here | What does NOT go here |
|-------|---------------|----------------------|
| **Commands** (`commands/`) | Typer option parsing, `OutputFormatter` calls, error-to-exit-code mapping | Business logic, HTTP calls, data transformation |
| **Services** (`services/`) | Orchestration, validation, data normalization, parallel execution | Typer imports, output formatting, raw HTTP |
| **Clients** (`client/` package, `manage_client.py`, etc.) | HTTP requests, URL construction, response parsing, retry logic | Business decisions, output formatting |

When adding a new feature, you will almost always touch all three layers.
If you find yourself importing `typer` in a service or calling `httpx` in a command, stop -- you're in the wrong layer.

### Thin commands, smart services

Commands are thin wrappers. All they do:
1. Parse Typer arguments
2. Call a service method
3. Format and output the result
4. Catch `KeboolaApiError` / `ConfigError` and map to exit codes

```python
# GOOD -- command is thin
@storage_app.command("create-bucket")
def storage_create_bucket(ctx, project, stage, name):
    formatter = get_formatter(ctx)
    service = get_service(ctx, "storage_service")
    try:
        result = service.create_bucket(alias=project, stage=stage, name=name)
    except KeboolaApiError as exc:
        formatter.error(message=exc.message, error_code=exc.error_code)
        raise typer.Exit(code=map_error_to_exit_code(exc)) from None
    formatter.output(result) if formatter.json_mode else ...

# BAD -- business logic leaked into command
@storage_app.command("create-bucket")
def storage_create_bucket(ctx, project, stage, name):
    if stage not in ("in", "out"):  # This belongs in service!
        ...
    client = KeboolaClient(...)     # This belongs in service!
    client.create_bucket(...)       # Commands don't call clients!
```

### Validate at system boundaries

User input coming through CLI arguments is a **system boundary** -- validate it.
Internal code passing data between layers is trusted -- don't over-validate.

Validation belongs in the **service layer** (not commands, not clients):
- Enum values (stage: `in`/`out`, column types, status filters)
- Format constraints (table ID format, bucket ID format)
- File existence checks
- Business rule validation

For CLI options with a small fixed set of values, prefer Typer/Click's `Choice`:
```python
stage: str = typer.Option(..., click_type=click.Choice(["in", "out"]))
```

### Dual output -- every command must support both modes

- `--json` mode: structured JSON via `formatter.output(data)`
- Human mode: Rich-formatted tables/text via `formatter.console.print()`

Never print raw text that breaks JSON parsing. Always check `formatter.json_mode`.

### Error handling

- Commands catch `KeboolaApiError` and `ConfigError`, map to exit codes
- Exit codes: 0=success, 1=general, 2=usage, 3=auth, 4=network, 5=config, 6=permission denied
- Multi-project operations accumulate errors -- one project failing doesn't stop others
- Use `raise typer.Exit(code=N) from None` to suppress traceback in CLI

### Constants -- no magic numbers

All configuration values go in `constants.py` or a dedicated config:
- Timeouts, retry counts, polling intervals
- Valid enum values (column types, stages)
- API endpoint paths if reused

```python
# BAD
time.sleep(2)
if retries > 3:

# GOOD
from .constants import POLL_INTERVAL, MAX_RETRIES
time.sleep(POLL_INTERVAL)
if retries > MAX_RETRIES:
```

## Code Quality Patterns

These are the *signal* patterns that distinguish hand-written quality code
from LLM-generated boilerplate. Every PR -- human or AI -- must adhere.
The `/kbagent:review` agent checks for these; the post-edit hooks in
`.claude/settings.json` run `ruff` + `ty` after every file write so drift is
caught immediately.

### Return values -- name them with dataclasses, not tuples

**Single value**: name the function after what it returns (`get_user_id`, `count_active_jobs`).

**Multiple values**: return a `@dataclass` (or `NamedTuple`/`BaseModel`) -- never a bare tuple beyond two values, and even two-element tuples should use a dataclass when the values are semantically distinct. Docstrings rot; dataclass field names do not.

```python
# BAD -- caller has to remember positional meaning
def resolve_project(alias: str | None) -> tuple[str, ProjectConfig]:
    ...

resolved_alias, project = resolve_project(alias)  # which is which?

# GOOD -- self-documenting at every call site
@dataclass(frozen=True)
class ResolvedProject:
    alias: str
    config: ProjectConfig

def resolve_project(alias: str | None) -> ResolvedProject:
    ...

resolved = resolve_project(alias)
resolved.alias, resolved.config  # unambiguous
```

Migration note: existing `tuple[...]` returns in services are grandfathered, but **do not add new ones**. When you touch one for an unrelated reason and the surface is small, convert it.

### Argument order -- stable first, variable last

Put **categorical / constant** arguments (error code, type, mode flag) BEFORE **dynamic / contextual** arguments (message text, payload). This matches the convention LLMs are trained on (most Python stdlib follows it: `logging.log(level, msg)`, `raise SomeError(code, message)`), so models will get the call sites right by default.

```python
# BAD -- LLMs will guess the order wrong
formatter.error(message="Bucket not found", error_code=ErrorCode.NOT_FOUND)

# GOOD -- category first, then the variable part
formatter.error(error_code=ErrorCode.NOT_FOUND, message="Bucket not found")

def log_failure(error_code: ErrorCode, message: str) -> None: ...
def raise_api_error(error_code: ErrorCode, *, message: str, status: int) -> None: ...
```

Required positional ordering ONLY when callers will pass positionally; otherwise keyword-only via `*,` and the order is moot at call sites but still matters in the signature for readability.

### Error codes -- enum only, never raw strings

All error codes go through `ErrorCode` (`src/keboola_agent_cli/errors.py`). Raw string literals like `"bucket_not_found"` or `"invalid_token"` are forbidden in `raise`, `formatter.error(error_code=...)`, and anywhere they cross a layer boundary. `make check-error-codes` rejects raw `error_code="..."` literals at CI time.

```python
# BAD
raise KeboolaApiError(message="...", error_code="not_found")

# GOOD
from .errors import ErrorCode
raise KeboolaApiError(error_code=ErrorCode.NOT_FOUND, message="...")
```

If a new category appears, **add it to `ErrorCode`** and `_ERROR_CODE_TO_TYPE` in the same PR. Do not introduce ad-hoc strings.

### File-size budgets -- split when concerns drift

Hard ceiling per file:

| Layer | Soft ceiling | Hard ceiling |
|-------|--------------|--------------|
| `commands/*.py` | 800 LOC | 1200 LOC |
| `services/*.py` | 1000 LOC | 1500 LOC |
| `client/*.py` (per module) / `manage_client.py` | 1500 LOC | 2000 LOC |

When a file crosses the **soft** ceiling, the next PR that adds material to it should split first. When a file crosses the **hard** ceiling, splitting is required before merging more functionality into it.

How to split:
- A client mixing multiple Keboola subsystems (Storage, Queue, Sandboxes, ...) → split by **endpoint family** into a package, e.g. `client/storage_tables.py`, `client/queue.py`, `client/configs.py`, composed into one class via mixins. Keep `BaseHttpClient` shared. (This is exactly what `client.py` -> the `client/` package was in #520.)
- A service crossing the ceiling almost always mixes orchestration with parsing/transformation → extract pure helpers into a sibling `_helpers.py` or `_transformers.py`.

This is a guideline driven by review feedback (kbagent 0.31.0: `client.py` ≈3000 LOC, `storage_service.py` ≈2180 LOC, `sync_service.py` ≈2765 LOC); the soft ceilings exist so the situation does not get worse before it gets better.

### Resource management -- `with` over lambdas

LLM-generated code routinely wraps `open()`/`httpx.Client()`/temp-file/lock creation in a lambda or a "create-and-forget" call, leaking file descriptors or connections. **Use a context manager every time the resource has `__enter__`/`__exit__`.**

```python
# BAD -- descriptor leaks if anything raises
opener = lambda: open(path, "r")  # noqa: avoid-lambda-as-resource
content = opener().read()

# BAD -- httpx client not closed on exception
client = httpx.Client()
response = client.get(url)

# GOOD
with open(path) as f:
    content = f.read()

with httpx.Client() as client:
    response = client.get(url)
```

### Named functions over throwaway lambdas

Single-expression `sort` keys and `filter` predicates are fine as lambdas. Anything else -- assigned to a variable, used multiple times, doing branching, or carrying domain meaning -- gets a named `def`. Names are the cheapest documentation in the codebase.

```python
# BAD
parse_row = lambda r: {"id": r[0], "name": r[1], "active": r[2] == "Y"}
rows = [parse_row(r) for r in raw]

# GOOD
def _parse_storage_row(raw: tuple[str, str, str]) -> dict[str, Any]:
    return {"id": raw[0], "name": raw[1], "active": raw[2] == "Y"}

rows = [_parse_storage_row(r) for r in raw]

# FINE -- single expression, throwaway, no domain meaning
items.sort(key=lambda x: x.priority)
```

### Type checking -- `ty` is mandatory and BLOCKING

We use Astral's [`ty`](https://github.com/astral-sh/ty) (same vendor as `uv` and `ruff`). It is fast (Rust), installs in <1s, and runs on every edit via the post-edit hook in `.claude/settings.json`. It also runs in the pre-commit hook and **blocks the commit on any type error** (the whole backlog was cleared in 0.45.0, see issue #280 PR-3), and is exposed via `make typecheck`.

Rules:
- **All code** -- `make typecheck` must stay clean (0 diagnostics). Adding any `# ty: ignore[rule]` requires a one-line comment explaining why (reserve it for genuinely dynamic surfaces, e.g. third-party stubs that mistype a runtime-valid argument).
- **No regressions** -- a newly introduced type error blocks both the post-edit and pre-commit hooks; fix it before continuing. Warnings (e.g. the downgraded `unresolved-import` rule) do not block.
- Type-hint every function signature (already a rule in "Python conventions" above); `ty` enforces that the hints are *correct*, not just present.

```bash
make typecheck         # full check, exit code reflects pass/fail
make typecheck-warn    # same, but always exit 0 (used by hooks)
```

## Keboola API Best Practices

### Reference implementation

The official Keboola CLI is written in Go: https://github.com/keboola/keboola-as-code

Before implementing any Keboola Storage API integration, **check how the official CLI does it**.
It is the authoritative source for correct API usage patterns -- endpoint selection,
async vs. sync behavior, polling strategies, and error handling. If our implementation
diverges from theirs, we need a documented reason why.

### Prefer async API endpoints over sync shortcuts

Many Storage API operations offer both sync and async variants. Sync endpoints are
simpler but have lower limits (e.g., file size caps, timeouts). Always use the async
variant for production code unless there is a specific reason not to.

Use `_wait_for_storage_job()` from the client (`client/_core.py`) for polling -- it already handles
intervals, backoff, timeout, and error extraction.

### Graceful resource creation (UX principle)

When the user's intent is clear (e.g., "upload data to this table"), don't force them
to manually create every prerequisite. If a bucket or table doesn't exist yet and can
be inferred from context, create it automatically and log what you did. The official
KBC CLI follows this pattern -- see `EnsureBucketExists()` in their codebase.

## Security Principles

### Permission engine -- register every new operation

Every CLI command must be registered in `OPERATION_REGISTRY` in `src/keboola_agent_cli/permissions.py`.
This maps operations to risk categories:

| Category | Examples | Risk |
|----------|----------|------|
| `read` | list, detail, status, search | No side effects |
| `write` | create, update, upload, load | Creates or modifies data |
| `destructive` | delete, force-delete | Destroys data |
| `admin` | org setup, project add/remove | Infrastructure-level |

**If you add a new command and forget to register it, the permission engine silently allows it** -- even when the user has set a restrictive policy. This is a security gap. Treat unregistered operations as a bug.

Pattern: `"{subapp}.{command}": "{category}"`, e.g.:
```python
"storage.create-bucket": "write",
"storage.create-table": "write",
"storage.upload-table": "write",
```

### Token security

- Tokens are **never** printed in full -- use `mask_token()` from `errors.py`
- Manage tokens: never persisted, never in CLI args, never logged
- Master tokens: via env var only (`KBC_MASTER_TOKEN` / `KBC_MASTER_TOKEN_{ALIAS}`)
- Never commit secrets to git -- not in code, not in docs, not in test fixtures

### Input validation for API-bound data

Validate user-supplied values before sending them to Keboola API:
- Column types against known base types: `STRING`, `INTEGER`, `NUMERIC`, `FLOAT`, `BOOLEAN`, `DATE`, `TIMESTAMP`
- Bucket stages: `in`, `out`
- File existence before upload attempts
- Table/bucket ID format where reasonable

Fail fast with clear error messages rather than letting the API return opaque errors.

## Checklist: Adding a New CLI Command

When adding a new command (e.g., `kbagent storage create-foo`), you must update **all** of these:

### Code changes

- [ ] **Client method** in the relevant `client/*.py` mixin (or `manage_client.py`) -- HTTP layer
- [ ] **Service method** in `services/` -- business logic, validation, orchestration
- [ ] **Command function** in `commands/` -- Typer options, formatter, error handling
- [ ] **Permission registration** in `permissions.py` (`OPERATION_REGISTRY` dict)
- [ ] **Service wiring** in `cli.py` if adding a new service class
- [ ] **HTTP API endpoint** in `src/keboola_agent_cli/server/routers/<group>.py` -- `kbagent serve` exposes the CLI as a REST API so external applications (Web UI, scheduled AI agents, Slack bots, Streamlit dashboards, CI pipelines) can call the platform without forking CLI subprocesses. The current convention is **1:1**: every command in a group has a matching endpoint in that group's router (e.g. `commands/flow.py` has 8 commands, `server/routers/flows.py` has 8 routes). If you add a new command, add the corresponding route. **Skip allowed** only for genuinely terminal-only commands (interactive prompts, Rich-rendered output that has no useful JSON shape, `doctor`/`init`/`update`-style infrastructure that manages kbagent itself rather than Keboola). Document any skip in the PR description with a one-line reason so reviewers don't flag it.

### Documentation changes (mandatory!)

CI catches drift in `SKILL.md` (decision table), `plugin.json` (version), and
`changelog.py` (release entries) -- but **NOT** in any of the hand-maintained
files below. Forgetting a hand-maintained file is a silent failure: tests pass,
lint passes, then three weeks later an AI agent quietly recommends a command
that does not exist (or refuses one that does). Treat the change as **not
done** until every box below is ticked.

#### CLI surface (kbagent itself)

- [ ] **`kbagent context`** -- update `AGENT_CONTEXT` string in `src/keboola_agent_cli/commands/context.py`. This is the primary reference loaded by AI agents at session start; if a command is missing here, agents will not know it exists.
- [ ] **`CLAUDE.md` `## All CLI Commands`** -- add the new command signature. Hand-maintained; must match `kbagent --help`.
- [ ] **`--help` text** -- Typer docstring and option help strings must read like a man-page entry. They are the ultimate fallback when documentation drifts.

#### Auto-generated (CI-checked, but you must run the generator)

- [ ] **`SKILL.md` decision table** -- run `make skill-gen` and commit the diff. **Do NOT edit the table by hand** -- the markers will be overwritten on the next `make skill-gen`. The pre-commit hook auto-regenerates and stages this file.
- [ ] **`plugin.json` version** -- bumped via `make version-sync` from `pyproject.toml`. The pre-commit hook auto-stages this file; you should never edit it by hand.

#### Plugin (`plugins/kbagent/`) -- HAND-MAINTAINED, NO CI CHECK

These files are how the Claude Code plugin teaches AI agents to use kbagent.
**None of them have a freshness check.** A failure here ships silently and
manifests as a drifted, unhelpful AI agent. Cross every one of them off
before the PR is mergeable.

- [ ] **`plugins/kbagent/agents/keboola-expert.md`** -- the subagent system prompt. **Highest silent-drift risk in the repo.** Update at minimum:
  - [ ] **§1 Rule 6 VERSION GATE** examples (e.g. `flow update needs 0.22.0+`) when adding a command that introduces or relaxes a minimum-version requirement, or when an example version reference is now stale enough to mislead.
  - [ ] **§2 Tool Selection Matrix** -- one row **per command GROUP**, not per command. When you add a new write/destructive *group* (e.g. `dev-portal`), give it a single row with `First choice / Fallback / NEVER`. Adding a command to an *existing* group needs no new row. Exhaustive per-command detail belongs in `AGENT_CONTEXT` (`kbagent context`), which is loaded dynamically on demand -- `keboola-expert.md` is a static system prompt loaded into every subagent run and carries a hard 60 KB budget, so it must stay a high-signal decision matrix, not a command catalogue. If a one-row addition would push the file over budget, trim stale content first; do **not** raise the cap. *Severity note:* authors are expected to add the group row, but `/kbagent:review` flags a missing row only **NON-BLOCKING** -- `AGENT_CONTEXT` (a BLOCKING surface above) is the authoritative command catalogue, so a missing matrix row degrades subagent ergonomics without making a command undiscoverable. Don't deprioritize it just because it's non-blocking.
  - [ ] **§3 Inline Gotchas** when behavior changed in a way the agent will get wrong by default (e.g. dev-branch auto-materialization, native column-type whitelisting).
- [ ] **`plugins/kbagent/skills/kbagent/SKILL.md`** non-table portions -- update the `description:` trigger keywords when introducing a new topic area (so description-matching auto-triggers the skill); add a workflow row to the bottom table if you created a new `references/<topic>-workflow.md`.
- [ ] **`plugins/kbagent/skills/kbagent/references/commands-reference.md`** -- add the new command bullet under the appropriate section. Hand-maintained, NOT auto-generated. (Yes, this partly duplicates the auto-generated SKILL.md table -- the reference carries denser per-command notes, the table is the at-a-glance picker.)
- [ ] **`plugins/kbagent/skills/kbagent/references/gotchas.md`** -- if the command's behavior is non-obvious, add an entry tagged with `(since vX.Y.Z)`. The version tag is **non-optional**; gotchas without versions are how AI agents end up recommending behavior that does not exist on older kbagent installs.
- [ ] **`plugins/kbagent/skills/kbagent/references/<topic>-workflow.md`** -- create a new file if the command introduces a new workflow or topic area (existing examples: `workspace-workflow.md`, `branch-workflow.md`, `sync-workflow.md`, `storage-files-workflow.md`, `storage-types-workflow.md`). Single-command additions go into an existing workflow file.
- [ ] **`plugins/kbagent/.claude-plugin/CLAUDE.md`** -- only update when the high-level delegation strategy changes (e.g. new "when NOT to delegate" cases). Most command additions do not touch this.
- [ ] **`plugins/kbagent/commands/keboola.md`** -- only update if the `/keboola` slash-command UX changes. Most command additions do not touch this.

### Tests (mandatory!)

- [ ] **Service-layer tests** -- mock the client, test business logic, edge cases, error propagation
- [ ] **CLI-layer tests** -- use `CliRunner`, test JSON output, error exit codes
- [ ] **E2E tests** -- add a test in `tests/test_e2e.py` that exercises the command against a real Keboola project (requires `E2E_API_TOKEN` + `E2E_URL`). Run `make test-e2e` to verify. Every CLI command must have E2E coverage

  > **Running locally without exporting a token:** if the target project is already registered in a kbagent `config.json`, use config-dir mode -- `make test-e2e-local CONFIG_DIR=/path/to/.kbagent ALIAS=my-proj`. The harness reads the token from `config.json` at import time and promotes it into `E2E_API_TOKEN` / `E2E_URL`; an explicit `E2E_API_TOKEN` still wins.

- [ ] **Run `make check`** before committing (lint + format + full test suite)
- [ ] **Run `make typecheck`** -- `ty` must pass clean (0 diagnostics; the backlog was cleared in 0.45.0, so the gate is blocking, not warning-only)
- [ ] **No new `tuple[...]` returns** -- multi-value returns use a `@dataclass` ([Code Quality Patterns](#code-quality-patterns))
- [ ] **No raw error-code strings** -- `make check-error-codes` enforces `ErrorCode` enum usage
- [ ] **File-size budgets respected** -- see the table in [Code Quality Patterns](#code-quality-patterns); split before crossing the hard ceiling

### UX considerations

- [ ] Human-mode output is informative (sizes, counts, progress indicators)
- [ ] JSON-mode output includes all relevant fields for programmatic consumption
- [ ] Error messages are actionable ("Bucket not found" not just "404")
- [ ] Destructive operations have `--dry-run` and `--yes` flags
- [ ] Write operations log what they did (created X, uploaded Y rows)

## Extending the importable SDK

Besides the CLI, kbagent ships an **in-process Python SDK** -- the importable
`Client` facade (`lib.py`) and its typed result models (`result_models.py`),
re-exported from the package root. A Keboola Data App, a transformation, or any
Python service can `from keboola_agent_cli import Client` and use Query Service
SQL, Storage Files, run-job, and config detail **without** a CLI subprocess or a
`kbagent serve` daemon.

**Everything exported from `keboola_agent_cli.__all__` is committed public API
under semver.** Changing it is a deliberate act, not a side effect of touching a
service. The full architecture, method reference, and the step-by-step checklist
for adding a facade method or a result model live in **[docs/sdk.md](docs/sdk.md)**
(see "Extending the SDK"). The short version:

- [ ] **Facade methods go in `lib.py`** and call `KeboolaClient` directly --
  never import the service layer (it carries config-dir / orchestration
  assumptions the stateless facade must not inherit). Re-assemble the
  high-level shape yourself, and **state in the docstring** which service
  conveniences you intentionally omit (auto-create, alias/variable resolution).
- [ ] **Return a typed model** (`result_models.py`), not a bare dict, for any
  non-trivial shape. Subclass `_ApiResultModel` (`extra="allow"` +
  `populate_by_name`); type **only** the stable subset, alias raw API keys via
  `AliasChoices`, and never `extra="forbid"` (the API grows fields and the
  contract must not raise).
- [ ] **Export it** from `__init__.py` + `__all__` -- that list *is* the public
  surface. Treat a field rename or a type tightening as a **breaking change**;
  prefer adding over mutating.
- [ ] **`make typecheck` stays clean** (types are a user-facing promise here),
  add facade/model tests in `tests/`, and **document the addition in
  [docs/sdk.md](docs/sdk.md)** (and the README "Use as a library" one-liner if
  it's a headline capability).

A runnable teaching example -- a curses Storage browser built entirely on the
SDK -- lives in [`examples/storage_tui/`](examples/storage_tui/).

## Plugin synchronization map

Single-glance reference for "I changed the CLI -- what else must follow?".
Use this table to cross-check the per-command checklist above and the
release checklist below.

| File | When to update | CI catches drift? |
|------|----------------|-------------------|
| `pyproject.toml` (`version`) | Every release | -- (single source of truth) |
| `src/keboola_agent_cli/changelog.py` | Every release | YES (`make changelog-check`) |
| `src/keboola_agent_cli/commands/context.py` (`AGENT_CONTEXT`) | Adding/removing/renaming commands; significant flag changes | NO |
| `src/keboola_agent_cli/server/routers/<group>.py` | Adding/removing/renaming commands -- `kbagent serve` mirrors the CLI 1:1 for external consumers (Web UI, scheduled agents, third-party apps). Skip only for terminal-only / kbagent-infrastructure commands; document skip in PR | NO -- callers get HTTP 404 instead of "command works in CLI but not via API" silent gap |
| `CLAUDE.md` (`## All CLI Commands`) | Adding/removing/renaming commands | NO |
| `plugins/kbagent/.claude-plugin/plugin.json` | Every release (auto-synced) | YES (`make version-check`; pre-commit auto-stages) |
| `plugins/kbagent/.claude-plugin/CLAUDE.md` | Changing delegation strategy / when-to-delegate rules | NO |
| `plugins/kbagent/agents/keboola-expert.md` | New write/destructive command **group** (one matrix row per group, not per command -- file has a hard 60 KB prompt budget); new minimum-version requirement (Rule 6 VERSION GATE); behavior change (gotchas) | NO -- **highest silent-drift risk** |
| `plugins/kbagent/commands/keboola.md` | `/keboola` slash-command UX change (rare) | NO |
| `plugins/kbagent/skills/kbagent/SKILL.md` -- table | Auto-generated by `make skill-gen` | YES (`make skill-check`; pre-commit auto-stages) |
| `plugins/kbagent/skills/kbagent/SKILL.md` -- description / rules / workflow links | New topic area in `description` triggers; new workflow file added to bottom table | NO |
| `plugins/kbagent/skills/kbagent/references/commands-reference.md` | Adding/removing/renaming commands; flag changes | NO |
| `plugins/kbagent/skills/kbagent/references/gotchas.md` | New non-obvious behavior -- always tag with `(since vX.Y.Z)` | NO |
| `plugins/kbagent/skills/kbagent/references/<topic>-workflow.md` | New workflow / topic area introduced | NO |

Anything tagged "NO" in the right column is a **silent failure mode**: lint
passes, tests pass, the AI agent goes off the rails three weeks later. The
per-command checklist (above) and the per-release checklist (below) exist
to catch this before the change ships.

## Commit & PR Conventions

- **No `Co-Authored-By`** lines in commit messages
- **No AI attribution footers** in PR descriptions
- **Conventional commits**: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`
- **One logical change per commit** -- don't mix unrelated fixes
- **Pre-commit hook must pass** -- `ruff check` + `ruff format --check`. Install via `make hooks`
- **Never skip hooks** (`--no-verify`) -- fix the lint issue instead
- **Protected main branch** -- always work on a feature branch, create PR, merge via GitHub
- **Self-review with `/kbagent:review` before tagging a human** -- see
  [Self-review before tagging a human reviewer](#self-review-before-tagging-a-human-reviewer)
  for what it does and how to run it. CI does not catch the silent-drift
  surfaces (Plugin synchronization map); the self-review does.

For reference on commit style: https://github.com/padak/claude-code-kit/blob/main/CLAUDE.md

## Self-review before tagging a human reviewer

Before you ping a maintainer, **run the `/kbagent:review` slash command
against your open PR**. It is a read-only specialist subagent
(`kbagent-pr-reviewer`, shipped with the kbagent Claude Code plugin) that
walks the same playbook a careful human reviewer would: 3-layer compliance,
[Plugin synchronization map](#plugin-synchronization-map) silent-drift hunt,
test coverage, behavior verification, backward compatibility, security and
token discipline. It posts ONE structured comment review on the PR with
findings rated BLOCKING / NON-BLOCKING / NIT, each carrying a `file:line`
citation.

### How to run it

1. Push your branch and open the PR (`gh pr create ...`).
2. Stay checked out on the PR's branch with a clean working tree.
3. Confirm `gh auth status` is authenticated to the same fork as the PR.
4. In a Claude Code session in the repo root, type:

   ```
   /kbagent:review
   ```

   The slash command auto-detects the PR for the current branch. To target a
   different PR explicitly:

   ```
   /kbagent:review 234
   /kbagent:review https://github.com/keboola/cli/pull/234
   /kbagent:review 234 focus on the new cache semantics
   ```

5. The reviewer reads `CONTRIBUTING.md`, walks the diff, runs `make check`,
   attempts to reproduce the PR's claimed behavior, and posts a single
   `gh pr review --comment` to the PR. It NEVER approves, requests changes,
   merges, or pushes -- the verdict in the comment body is advice; you and
   the human reviewer retain every veto.

### What to do with the findings

- **BLOCKING** -- address before tagging a human, OR push back in a PR
  comment explaining why you disagree. Some BLOCKING findings are
  calibration mistakes; the reviewer defaults conservative, and a
  ~30-second human disposition is faster than a re-run.
- **NON-BLOCKING** -- address if quick; otherwise mention them in the PR
  description so the human reviewer knows they are not regressions hiding
  in the diff.
- **NIT** -- optional. Address if you agree.

### This is a courtesy, not a CI gate

The reviewer is intentionally NOT wired into CI. It depends on Claude Code
with the kbagent plugin installed and an authenticated `gh`, which is not
portable across all contributor setups. Running it remains a per-author
courtesy that:

- catches the silent-drift gaps (`OPERATION_REGISTRY`, `gotchas.md`
  version tags, `keboola-expert.md` matrix, `commands/context.py`
  `AGENT_CONTEXT`, `commands-reference.md`) that CI does not check;
- demonstrates to the human reviewer that you have walked the
  [Plugin synchronization map](#plugin-synchronization-map);
- saves a review round-trip when the reviewer would otherwise catch the
  same issues.

If you genuinely cannot run it (offline, no `gh` auth, plugin not
installed), say so explicitly in the PR description (`self-review
skipped: <reason>`) -- the human reviewer may run it on your behalf, or
ask you to address it before merge.

## Testing Guidelines

- Use `typer.testing.CliRunner` for CLI tests
- Use `unittest.mock.MagicMock` for mocking services and clients
- Use `pytest` fixtures from `conftest.py` (`tmp_config_dir`, `config_store`, etc.)
- Test both success and error paths
- Test JSON output parsing (`json.loads(result.output)["data"]`)
- Verify `client.close()` is called (via `mock_client.close.assert_called_once()`)
- Test edge cases: missing project alias, API errors, invalid input
- Match test file naming: `test_{feature}.py` or `test_{feature}_cli.py`

## Releasing a new version

A "release" is whenever you bump `pyproject.toml`'s version. Tag a feature
branch, walk this checklist end-to-end, then merge to `main`. The point of
steps 5-8 is that **CI will not catch you** if you skip them; they are the
manual safety net for the silent-drift risks summarized in the
[Plugin synchronization map](#plugin-synchronization-map) above.

> **Want to ship a beta first?** You can. PEP 440 pre-release versions
> (`0.43.0b1`, `0.43.0rc1`) are fully supported by `kbagent update --beta`
> since v0.42.0. The startup auto-update hook never silently lands on a
> beta -- only explicit opt-in installs them. See
> [Releasing a beta (pre-release) version](#releasing-a-beta-pre-release-version)
> below for the workflow.

1. **Edit `pyproject.toml`** -- bump `version = "X.Y.Z"`. Single source of truth; everything else derives from it.
2. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- one entry per release, no exceptions. CI fails (`make changelog-check`) if this is missing. Author it as the file's docstring describes: **one logical change per bullet** (split the release into several list items rather than one mega-paragraph), each starting with a recognised prefix (`BREAKING:`, `New:`, `Fix:`, `Change:`, `Note:`, `Security:`, ...) and leading with a self-contained first sentence. `kbagent changelog` shows only that first sentence per version by default (the rest is revealed by `--full`), so a buried headline or a single wall-of-text bullet reads as an unscannable blob.
3. **Run `make version-sync`** -- propagates the new version to `plugins/kbagent/.claude-plugin/plugin.json`. The pre-commit hook does this automatically on `git commit`, but running it explicitly lets you eyeball the diff.
4. **Run `make skill-gen`** -- regenerates the decision table in `SKILL.md`. Idempotent if no commands changed since the previous release.
5. **Manually review `plugins/kbagent/agents/keboola-expert.md`**:
   - **§1 Rule 6 VERSION GATE examples** -- if any feature this release shipped (or any feature shipped in a previous release that you missed) was previously missing-and-now-present, document it with the right minimum version. Remove stale "since X.Y.Z" mentions that no longer matter to live users.
   - **§2 Tool Selection Matrix** -- did you add a new write/destructive command *group* since last release? Is it present with one `First choice / Fallback / NEVER` row (per group, not per command)? Mind the hard 60 KB prompt budget: trim stale content rather than raising the cap. New commands inside an existing group need no new row.
   - **§3 Inline Gotchas** -- new behavior the agent would get wrong by default? Add it.
6. **Manually review `plugins/kbagent/skills/kbagent/references/gotchas.md`** -- every behavior introduced or changed this release that an AI agent would not infer from `--help` should have its own `(since vX.Y.Z)` entry. The version tag is non-optional.
7. **Manually review `CLAUDE.md` `## All CLI Commands`** -- diff against `kbagent --help` output (and against `kbagent context`). Hand-maintained; CI does not catch drift here.
8. **Manually review `plugins/kbagent/skills/kbagent/references/commands-reference.md`** -- same drill. Hand-maintained, no CI coverage.
9. **Run `make check`** -- lint + format + skill freshness + version sync + changelog completeness + error-code enum + full test suite.
10. **Run `make test-e2e`** if you changed any command -- requires `E2E_API_TOKEN` and `E2E_URL`.
11. **Open a PR** -- list every plugin file you touched in the description so reviewers can spot what was missed. Plugin files do not auto-show up in CI failures the way Python files do; reviewers are the second line of defence.
12. **Merge via `gh pr merge`** -- never push directly to `main` (the branch is protected; this would fail anyway).

If any of steps 5-8 reveal "I should have done this in the PR that introduced
the command, not at release time", **also patch the per-command checklist**
above so the next contributor catches the gap earlier.

### Releasing a beta (pre-release) version

Beta and release-candidate versions follow PEP 440: `X.Y.Zb1`, `X.Y.Zb2`,
`X.Y.Zrc1`, ... -- not the SemVer `-beta.1` form (hatchling and uv require
PEP 440 syntax in `pyproject.toml`'s `version` field). Two gates keep stable
users safe from accidentally landing on a beta:

1. **Version string itself.** PEP 440 marks any pre-release suffix as such;
   `pip install keboola-cli` and `uv tool install ...` default to
   **skipping** pre-releases unless the resolver is told otherwise (`--pre`
   for pip, `--prerelease=allow` for uv).
2. **GitHub Release `prerelease: true` flag.** The auto-update startup
   hook calls `GET /releases/latest`, which GitHub explicitly defines as
   "the most recent non-prerelease, non-draft release". Marking the release
   `--prerelease` makes it invisible to the auto-update path.

**Workflow:**

1. Bump `pyproject.toml` to the PEP 440 pre-release version
   (e.g. `0.43.0b1`).
2. Add a changelog entry under that key in `src/keboola_agent_cli/changelog.py`.
3. `make version-sync` propagates the version to `plugin.json`,
   `marketplace.json`, and the `uv.lock` self-version pin.
4. Tag and push: `git tag v0.43.0b1 && git push origin v0.43.0b1`.
5. Create the GitHub release **with the `--prerelease` flag**:
   ```bash
   gh release create v0.43.0b1 --prerelease \
       --title "v0.43.0 — Beta 1" \
       --notes-file release-notes-0.43.0b1.md
   ```
6. Test by installing yourself: `kbagent update --beta` (or set
   `KBAGENT_INCLUDE_PRERELEASE=1` in env). Users who do **not** opt in
   keep getting the latest stable; the new beta is invisible to them.
7. Once the beta cooks long enough, bump to the stable equivalent
   (`0.43.0`), retag, and create the release **without** `--prerelease`
   so auto-update picks it up.

**Rebasing a beta onto a moved `main`.** Tags are immutable and pinned to a
commit; rebasing the feature branch (to clear merge conflicts or pull in
newer `main` fixes) leaves the existing `vX.Y.Zb1` tag pointing at the
now-orphaned pre-rebase commit. Do **not** force-move a published tag --
cut the next pre-release number instead:

1. Rebase the branch and force-push it (`git push --force-with-lease`).
2. Bump `pyproject.toml` to the next beta (`0.44.0b1` -> `0.44.0b2`), add a
   short changelog entry noting "rebased onto current main, no behaviour
   change", and `make version-sync`.
3. Commit + push, then tag the rebased HEAD:
   `git tag v0.44.0b2 && git push origin v0.44.0b2`.
4. `gh release create v0.44.0b2 --prerelease ...`. Leave the old `b1`
   tag/release intact as history -- it documents the earlier base.

Every published tag stays immutable (a tester who pinned `b1` still gets
exactly what `b1` always was), while `kbagent update --beta` resolves to the
highest PEP 440 version -- the freshly rebased `b2`.

**Users opt in two ways:**

- One-shot: `kbagent update --beta` (resolver is told `--prerelease=allow`
  / `--pre`, GitHub query switches to `/releases` and picks the highest
  PEP 440 version including pre-releases).
- Per-session env var: `export KBAGENT_INCLUDE_PRERELEASE=1` -- every
  subsequent `kbagent update` / `kbagent version` in that shell treats
  betas as installable.

**Never persists.** There is no `release_channel: beta` config setting --
each invocation has to opt in. This is deliberate: betas should always be
an active choice, never a forgotten "I once typed --beta six months ago"
foot-gun.

## Running CI Locally

```bash
make check              # CI parity: lint + format + typecheck + skill + version + command-sync + changelog + error-codes + test
make lint               # Just the ruff linter
make format             # Auto-format code
make typecheck          # Static type check (Astral `ty`)
make test               # Just the test suite (no coverage)
make test-cov           # Test suite + informational coverage report (term-missing)
make command-sync-check # Verify every CLI command is registered + documented
make skill-gen          # Regenerate SKILL.md from CLI command metadata
```

Always run `make check` before pushing. The PR won't pass CI if lint or tests fail.

**SKILL.md freshness check**: CI verifies that `plugins/kbagent/skills/kbagent/SKILL.md` matches the auto-generated output from `make skill-gen`. If you added, removed, or renamed any CLI command, run `make skill-gen` and commit the result. Manual edits to the decision table will be overwritten and will cause CI to fail.

## CI workflows

Two GitHub Actions workflows guard the repo:

### `.github/workflows/ci.yml` -- per-PR gate (push + pull_request to main)

- **`check` job** (one run, Python 3.12): the static half of `make check` --
  lint, format, `ty` type-check, SKILL.md freshness, version consistency, the
  command-sync silent-drift gate, and the error-code enum check. These are
  deterministic and interpreter-independent, so they do not fan out across the
  matrix. (`changelog-check` stays local-only: it needs `gh` auth and audits
  published releases, a release-time concern -- not a per-PR gate.)
- **`test` job** (matrix: Python 3.12 + 3.13): the unit/CLI suite
  (`-m "not integration"`; `e2e` self-skips without credentials). Coverage is
  printed (`--cov ... --cov-report=term-missing`) but **informational** --
  there is no `--cov-fail-under` threshold, so coverage never blocks a merge.
- **`build-windows` job**: real `uv build` wheel checks (issue #320).

`make check` runs the same gates as the `check` + `test` CI jobs locally and is
slightly *stricter*: its `test` target uses `-m "not e2e"`, so it also runs the
`integration` tests that CI's `test` job deselects (`-m "not integration"`).
Those integration tests skip or pass without credentials -- they never fail
offline -- so a green `make check` implies CI's narrower selection passes too.
Run it before pushing.

**Command-sync silent-drift gate** (`scripts/check_command_sync.py`): treats the
live Typer command tree as the single source of truth and fails if any command
is missing from `permissions.py` `OPERATION_REGISTRY`, `CLAUDE.md`
`## All CLI Commands`, `commands/context.py` `AGENT_CONTEXT`, or
`commands-reference.md`. It also flags dead `OPERATION_REGISTRY` keys (renamed /
removed commands). This is the deterministic half of the "Plugin synchronization
map" -- the judgement half (is a behaviour change worth a new gotcha? is the
`(since vX.Y.Z)` tag right?) is left to `/kbagent:review`.

### `.github/workflows/e2e.yml` -- nightly + on-demand (NOT per-PR)

The end-to-end suite hits a real Keboola API and mutates live resources, so it
is **not** wired into the PR gate (too slow, too flaky, and it would churn a
real project on every push). Instead it runs **nightly** (cron `17 3 * * *`
UTC) and **on demand** via the Actions tab (`workflow_dispatch`).

**One-time setup (maintainer):**

1. Create a **dedicated throwaway** Keboola project -- the suite creates and
   deletes buckets, tables, workspaces, and data apps, so never point it at a
   project whose data you care about.
2. Add two repository secrets (Settings > Secrets and variables > Actions):
   - `E2E_API_TOKEN` -- a Storage API token for that project,
   - `E2E_URL` -- the stack host, e.g. `connection.<region>.keboola.com`.
3. Optionally trigger a manual run from the Actions tab to verify the wiring.

If the secrets are absent the workflow still **succeeds** (green) but emits a
warning and skips the suite -- so a fork or an unconfigured repo never sees a
spurious red E2E failure. Fork PRs never receive secrets, by design.
