From 9e0c27e4f832a2c32040f01312870f351f932315 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 14 Apr 2026 17:04:21 +0200 Subject: [PATCH 1/3] feat: add Kai (Keboola AI Assistant) CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add kbagent kai {ping,ask,chat,history} — bridges Claude Code to Kai cloud: - kai ping: health check with MCP connection status - kai ask -m "question": one-shot question, collect full response - kai chat -m "msg" [--chat-id ID]: send message in chat session - kai history [--limit N]: list recent chat sessions Architecture: - commands/kai.py (CLI layer) + services/kai_service.py (business logic) - Uses kai-client library (PyPI) for async HTTP/SSE/auth - Auto-discovers kai-assistant URL from Storage API services list - Feature detection: checks owner.features for "agent-chat" flag - Permission system: kai.ping/ask=read, kai.chat=write, kai.history=read Also: extend TokenVerifyResponse with features list, add KAI_* constants. --- plugins/kbagent/skills/kbagent/SKILL.md | 4 + pyproject.toml | 1 + src/keboola_agent_cli/cli.py | 5 + src/keboola_agent_cli/client.py | 1 + src/keboola_agent_cli/commands/kai.py | 188 ++++++++++++++ src/keboola_agent_cli/constants.py | 5 + src/keboola_agent_cli/models.py | 4 + src/keboola_agent_cli/permissions.py | 5 + src/keboola_agent_cli/services/kai_service.py | 242 ++++++++++++++++++ uv.lock | 17 ++ 10 files changed, 472 insertions(+) create mode 100644 src/keboola_agent_cli/commands/kai.py create mode 100644 src/keboola_agent_cli/services/kai_service.py diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 1fa1fbd1..1a644bc7 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -97,6 +97,10 @@ If kbagent is not installed or you need the full standalone reference, run `kbag | Link a shared bucket into a project | `kbagent sharing link --project PROJECT --source-project-id SOURCE-PROJECT-ID --bucket-id BUCKET-ID` | | Remove a linked bucket from a project | `kbagent sharing unlink --project PROJECT --bucket-id BUCKET-ID` | | Show cross-project data lineage via bucket sharing | `kbagent lineage show` | +| Check Kai server health and MCP connection status | `kbagent kai ping` | +| Ask Kai a one-shot question and get the full response | `kbagent kai ask --message MESSAGE` | +| Send a message to Kai in a chat session | `kbagent kai chat --message MESSAGE` | +| List recent Kai chat sessions | `kbagent kai history` | | List development branches from connected projects | `kbagent branch list` | | Create a new development branch and auto-activate it | `kbagent branch create --project PROJECT --name NAME` | | Set an existing development branch as active | `kbagent branch use --project PROJECT --branch BRANCH` | diff --git a/pyproject.toml b/pyproject.toml index d4f99bfd..f421f65f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pyyaml>=6", "packaging>=23", "prompt-toolkit>=3.0", + "kai-client>=0.11.0", ] [project.scripts] diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 384b9dd2..de8a1844 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -15,6 +15,7 @@ from .commands.encrypt import encrypt_app from .commands.init import init_command from .commands.job import job_app +from .commands.kai import kai_app from .commands.lineage import lineage_app from .commands.org import org_app from .commands.permissions import permissions_app @@ -37,6 +38,7 @@ from .services.doctor_service import DoctorService from .services.encrypt_service import EncryptService from .services.job_service import JobService +from .services.kai_service import KaiService from .services.lineage_service import LineageService from .services.mcp_service import McpService from .services.org_service import OrgService @@ -77,6 +79,7 @@ app.add_typer(storage_app, name="storage", rich_help_panel=_BROWSE) app.add_typer(sharing_app, name="sharing", rich_help_panel=_BROWSE) app.add_typer(lineage_app, name="lineage", rich_help_panel=_BROWSE) +app.add_typer(kai_app, name="kai", rich_help_panel=_BROWSE) # -- Development -- _DEV = "Development" @@ -186,6 +189,7 @@ def main( sync_service = SyncService(config_store=config_store) encrypt_service = EncryptService(config_store=config_store) workspace_service = WorkspaceService(config_store=config_store) + kai_service = KaiService(config_store=config_store) doctor_service = DoctorService(config_store=config_store, mcp_service=mcp_service) version_service = VersionService() @@ -224,6 +228,7 @@ def main( ctx.obj["sync_service"] = sync_service ctx.obj["encrypt_service"] = encrypt_service ctx.obj["workspace_service"] = workspace_service + ctx.obj["kai_service"] = kai_service ctx.obj["doctor_service"] = doctor_service ctx.obj["version_service"] = version_service diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index cab4cee5..a4712ae9 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -189,6 +189,7 @@ def verify_token(self) -> TokenVerifyResponse: project_name=owner.get("name", ""), owner_name=owner.get("name", ""), default_backend=owner.get("defaultBackend", "snowflake"), + features=owner.get("features", []), ) def list_components( diff --git a/src/keboola_agent_cli/commands/kai.py b/src/keboola_agent_cli/commands/kai.py new file mode 100644 index 00000000..ec5be638 --- /dev/null +++ b/src/keboola_agent_cli/commands/kai.py @@ -0,0 +1,188 @@ +"""CLI commands for Kai (Keboola AI Assistant) integration. + +Bridges Claude Code (local) to Kai (cloud) via kbagent CLI. +Kai has MCP access to project data and can answer Keboola-specific questions. +""" + +import typer + +from ..errors import ConfigError, KeboolaApiError +from ._helpers import check_cli_permission, get_formatter, get_service, map_error_to_exit_code + +kai_app = typer.Typer(help="Keboola AI Assistant (Kai) — ask questions about your project") + + +@kai_app.callback(invoke_without_command=True) +def _kai_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "kai") + + +@kai_app.command("ping") +def kai_ping( + ctx: typer.Context, + project: str | None = typer.Option( + None, + "--project", + help="Project alias (uses default if omitted).", + ), +) -> None: + """Check Kai server health and MCP connection status.""" + formatter = get_formatter(ctx) + service = get_service(ctx, "kai_service") + + try: + alias = service.resolve_alias(project) + result = service.ping(alias) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + 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 + + def _human(console, data): + console.print(f"[bold green]Kai is alive[/bold green] ({data['project_alias']})") + console.print(f" Timestamp: {data['timestamp']}") + console.print(f" App: {data['app_name']} {data['app_version']}") + console.print(f" Server: {data['server_version']}") + console.print(f" MCP connection: {data['mcp_status']}") + + formatter.output(result, _human) + + +@kai_app.command("ask") +def kai_ask( + ctx: typer.Context, + message: str = typer.Option( + ..., + "--message", + "-m", + help="Question to ask Kai about your project.", + ), + project: str | None = typer.Option( + None, + "--project", + help="Project alias (uses default if omitted).", + ), +) -> None: + """Ask Kai a one-shot question and get the full response. + + Kai has access to your project's data, configurations, and lineage + via MCP tools. Use this for Keboola-specific questions that require + project context. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "kai_service") + + try: + alias = service.resolve_alias(project) + result = service.ask(alias, message) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + 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 + + def _human(console, data): + console.print(data["response"]) + + formatter.output(result, _human) + + +@kai_app.command("chat") +def kai_chat( + ctx: typer.Context, + message: str = typer.Option( + ..., + "--message", + "-m", + help="Message to send to Kai.", + ), + chat_id: str | None = typer.Option( + None, + "--chat-id", + help="Continue an existing chat session.", + ), + project: str | None = typer.Option( + None, + "--project", + help="Project alias (uses default if omitted).", + ), +) -> None: + """Send a message to Kai in a chat session. + + Use --chat-id to continue a previous conversation. + Without --chat-id, starts a new chat. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "kai_service") + + try: + alias = service.resolve_alias(project) + result = service.chat_message(alias, message, chat_id=chat_id) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + 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 + + def _human(console, data): + console.print(data["response"]) + console.print(f"\n[dim]Chat ID: {data['chat_id']}[/dim]") + + formatter.output(result, _human) + + +@kai_app.command("history") +def kai_history( + ctx: typer.Context, + project: str | None = typer.Option( + None, + "--project", + help="Project alias (uses default if omitted).", + ), + limit: int = typer.Option( + 10, + "--limit", + "-n", + help="Maximum number of chats to return.", + ), +) -> None: + """List recent Kai chat sessions.""" + formatter = get_formatter(ctx) + service = get_service(ctx, "kai_service") + + try: + alias = service.resolve_alias(project) + result = service.get_history(alias, limit=limit) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + 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 + + def _human(console, data): + chats = data["chats"] + if not chats: + console.print("[dim]No chat history.[/dim]") + return + from rich.table import Table + + table = Table(title=f"Kai Chat History ({data['project_alias']})") + table.add_column("Chat ID", style="cyan", no_wrap=True) + table.add_column("Title") + table.add_column("Created", style="dim") + for chat in chats: + table.add_row( + chat["id"][:12] + "...", + chat["title"], + chat["created_at"] or "", + ) + console.print(table) + if data["has_more"]: + console.print("[dim]More chats available. Use --limit to see more.[/dim]") + + formatter.output(result, _human) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index bd6933f8..7108d381 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -106,6 +106,11 @@ # --- AI Service --- AI_SERVICE_TIMEOUT: httpx.Timeout = httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=5.0) + +# --- Kai (Keboola AI Assistant) --- +KAI_FEATURE_FLAG: str = "agent-chat" +KAI_REQUEST_TIMEOUT: float = 300.0 # 5 min for non-streaming requests +KAI_STREAM_TIMEOUT: float = 600.0 # 10 min for SSE streaming responses SECRET_PLACEHOLDER: str = "" # --- Job Run --- diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 90fac7c6..76475e31 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -100,6 +100,10 @@ class TokenVerifyResponse(BaseModel): default="snowflake", description="Project default backend (snowflake, bigquery, etc.)", ) + features: list[str] = Field( + default_factory=list, + description="Project feature flags (e.g. agent-chat, storage-types)", + ) class ComponentDetail(BaseModel): diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index f4a908a6..37ae2435 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -60,6 +60,11 @@ # MCP tools "tool.list": "read", "tool.call": "write", + # Kai (Keboola AI Assistant) + "kai.ping": "read", + "kai.ask": "read", + "kai.chat": "write", + "kai.history": "read", # Component discovery "component.list": "read", "component.detail": "read", diff --git a/src/keboola_agent_cli/services/kai_service.py b/src/keboola_agent_cli/services/kai_service.py new file mode 100644 index 00000000..15f4b6c7 --- /dev/null +++ b/src/keboola_agent_cli/services/kai_service.py @@ -0,0 +1,242 @@ +"""Kai (Keboola AI Assistant) service — bridge between CLI and cloud Kai API. + +Provides sync wrappers around the async kai-client library, with feature +detection (agent-chat flag) and project resolution via BaseService. +""" + +import asyncio +import logging +from typing import Any + +from kai_client import KaiClient, KaiError + +from ..constants import KAI_FEATURE_FLAG, KAI_REQUEST_TIMEOUT, KAI_STREAM_TIMEOUT +from ..errors import ConfigError, KeboolaApiError +from .base import BaseService + +logger = logging.getLogger(__name__) + + +class KaiService(BaseService): + """Business logic for Kai AI Assistant integration. + + All public methods are synchronous — they wrap the async KaiClient + via asyncio.run() so Typer commands can call them directly. + """ + + # ------------------------------------------------------------------ + # Project resolution + # ------------------------------------------------------------------ + + def resolve_alias(self, alias: str | None) -> str: + """Resolve a project alias, falling back to the default project. + + Args: + alias: Explicit alias, or None for default. + + Returns: + Resolved alias string. + + Raises: + ConfigError: If no projects configured or alias not found. + """ + if alias: + # Validate it exists + self.resolve_projects([alias]) + return alias + # Fall back to default (first project) + projects = self.resolve_projects() + if not projects: + raise ConfigError("No projects configured. Run 'kbagent project add' first.") + return next(iter(projects)) + + # ------------------------------------------------------------------ + # Feature detection + # ------------------------------------------------------------------ + + def _check_kai_enabled(self, alias: str) -> None: + """Raise KeboolaApiError if Kai is not enabled for the project. + + Calls verify_token to check owner.features for the agent-chat flag. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + client = self._client_factory(project.stack_url, project.token) + try: + token_info = client.verify_token() + finally: + client.close() + + if KAI_FEATURE_FLAG not in token_info.features: + raise KeboolaApiError( + message=( + f"Kai is not enabled for project '{alias}'. " + "Enable the 'AI Agent Chat' feature in project settings." + ), + status_code=0, + error_code="KAI_NOT_ENABLED", + ) + + # ------------------------------------------------------------------ + # Async helpers + # ------------------------------------------------------------------ + + async def _create_kai_client(self, alias: str) -> KaiClient: + """Create a KaiClient with auto-discovered URL for the given project.""" + projects = self.resolve_projects([alias]) + project = projects[alias] + return await KaiClient.from_storage_api( + storage_api_token=project.token, + storage_api_url=project.stack_url, + timeout=KAI_REQUEST_TIMEOUT, + stream_timeout=KAI_STREAM_TIMEOUT, + ) + + # ------------------------------------------------------------------ + # Public methods (sync wrappers) + # ------------------------------------------------------------------ + + def ping(self, alias: str) -> dict[str, Any]: + """Check Kai server health for a project. + + Returns: + Dict with timestamp and server info. + """ + self._check_kai_enabled(alias) + + async def _ping() -> dict[str, Any]: + client = await self._create_kai_client(alias) + async with client: + ping_resp = await client.ping() + info_resp = await client.info() + + return { + "project_alias": alias, + "timestamp": ping_resp.timestamp.isoformat(), + "app_name": info_resp.app_name, + "app_version": info_resp.app_version, + "server_version": info_resp.server_version, + "mcp_status": ( + info_resp.connected_mcp.get("status", "unknown") + if isinstance(info_resp.connected_mcp, dict) + else "unknown" + ), + } + + try: + return asyncio.run(_ping()) + except KaiError as exc: + raise KeboolaApiError( + message=f"Kai ping failed: {exc.message}", + status_code=0, + error_code="KAI_ERROR", + ) from exc + + def ask(self, alias: str, message: str) -> dict[str, Any]: + """Send a one-shot question to Kai and collect the full text response. + + Args: + alias: Project alias. + message: The question to ask. + + Returns: + Dict with chat_id and response text. + """ + self._check_kai_enabled(alias) + + async def _ask() -> dict[str, Any]: + client = await self._create_kai_client(alias) + async with client: + chat_id, response_text = await client.chat(message) + + return { + "project_alias": alias, + "chat_id": chat_id, + "response": response_text, + } + + try: + return asyncio.run(_ask()) + except KaiError as exc: + raise KeboolaApiError( + message=f"Kai ask failed: {exc.message}", + status_code=0, + error_code="KAI_ERROR", + ) from exc + + def chat_message(self, alias: str, message: str, chat_id: str | None = None) -> dict[str, Any]: + """Send a message in a chat session and collect the response. + + Args: + alias: Project alias. + message: The message to send. + chat_id: Optional existing chat ID to continue. + + Returns: + Dict with chat_id and response text. + """ + self._check_kai_enabled(alias) + + async def _chat() -> dict[str, Any]: + client = await self._create_kai_client(alias) + async with client: + cid = chat_id or client.new_chat_id() + response_parts: list[str] = [] + async for event in client.send_message(cid, message): + if event.type == "text": + response_parts.append(event.text) # type: ignore[attr-defined] + + return { + "project_alias": alias, + "chat_id": cid, + "response": "".join(response_parts), + } + + try: + return asyncio.run(_chat()) + except KaiError as exc: + raise KeboolaApiError( + message=f"Kai chat failed: {exc.message}", + status_code=0, + error_code="KAI_ERROR", + ) from exc + + def get_history(self, alias: str, limit: int = 10) -> dict[str, Any]: + """Get chat history for the current user. + + Args: + alias: Project alias. + limit: Max number of chats to return. + + Returns: + Dict with list of chat summaries. + """ + self._check_kai_enabled(alias) + + async def _history() -> dict[str, Any]: + client = await self._create_kai_client(alias) + async with client: + history = await client.get_history(limit=limit) + + return { + "project_alias": alias, + "chats": [ + { + "id": chat.id, + "title": chat.title or "(untitled)", + "created_at": chat.created_at.isoformat() if chat.created_at else None, + "visibility": chat.visibility, + } + for chat in history.chats + ], + "has_more": history.has_more, + } + + try: + return asyncio.run(_history()) + except KaiError as exc: + raise KeboolaApiError( + message=f"Kai history failed: {exc.message}", + status_code=0, + error_code="KAI_ERROR", + ) from exc diff --git a/uv.lock b/uv.lock index 4103e615..d7719456 100644 --- a/uv.lock +++ b/uv.lock @@ -406,6 +406,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kai-client" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/ab/67459462a6fa561d8264fc2a615cf96a091755bc9328ff13c57c74778a59/kai_client-0.11.0.tar.gz", hash = "sha256:f3d42c96a8f92c56af784570d3b07c10701ffec29ce9cf0a4b8a4af374798d7a", size = 101197, upload-time = "2026-02-21T14:10:05.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/58/271197c664cee82c6775830945f46776ef74dc70f2e711b4087115ae8d88/kai_client-0.11.0-py3-none-any.whl", hash = "sha256:cbd173a805888cf2d46a841be1b8ae193a0f1237eafda0a8b0e589f8f006b81e", size = 26690, upload-time = "2026-02-21T14:10:04.144Z" }, +] + [[package]] name = "keboola-agent-cli" version = "0.18.6" @@ -413,6 +428,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "jsonschema" }, + { name = "kai-client" }, { name = "mcp" }, { name = "packaging" }, { name = "platformdirs" }, @@ -437,6 +453,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "jsonschema", specifier = ">=4.20" }, + { name = "kai-client", specifier = ">=0.11.0" }, { name = "mcp", specifier = ">=1.0.0,<2.0.0" }, { name = "packaging", specifier = ">=23" }, { name = "platformdirs", specifier = ">=4" }, From 454e8417381ebbf64e93a0bfcb3506f8d49e5574 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 14 Apr 2026 17:23:14 +0200 Subject: [PATCH 2/3] test: add Kai E2E tests + update CLAUDE.md with kai commands Add kai ping/ask/history to E2E test suite (step 38) with graceful skip when agent-chat feature not enabled or auth fails. Update CLAUDE.md All CLI Commands section with kai commands. Update e2e-scenarios.md with Kai phase documentation. --- CLAUDE.md | 5 ++++ docs/e2e-scenarios.md | 10 +++++++ tests/test_e2e.py | 65 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9f0379a..02921b24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,6 +305,11 @@ kbagent config new --component-id ID [--name NAME] [--project NAME] [--output-di kbagent encrypt values --project ALIAS --component-id ID --input JSON|@file|- [--output-file PATH] +kbagent kai ping [--project NAME] +kbagent kai ask --message "question" [--project NAME] +kbagent kai chat --message "msg" [--chat-id ID] [--project NAME] +kbagent kai history [--project NAME] [--limit N] + kbagent context kbagent init [--from-global] kbagent doctor [--fix] diff --git a/docs/e2e-scenarios.md b/docs/e2e-scenarios.md index 3d5fd634..e54d794a 100644 --- a/docs/e2e-scenarios.md +++ b/docs/e2e-scenarios.md @@ -132,6 +132,16 @@ Steps 21-27 are wrapped in try/except -- if workspace API is unavailable on the |-----:|---------|------------------| | 37 | `sharing list`, `lineage show` | Both return valid responses (may be empty on single project) | +### Phase 12.5: Kai (Keboola AI Assistant) + +| Step | Command | What is verified | +|-----:|---------|------------------| +| 38 | `kai ping` | Server health, timestamp, MCP status. Gracefully skips all kai tests if `agent-chat` feature not enabled | +| 38 | `kai ask -m "..."` | One-shot question, verify response text + chat_id. Skips if auth fails (token type) | +| 38 | `kai history --limit 5` | At least 1 chat after asking | + +Steps are wrapped in graceful skip logic — if Kai is not available (feature flag or auth), remaining kai tests are skipped without failing. + ### Phase 13: Job commands | Step | Command | What is verified | diff --git a/tests/test_e2e.py b/tests/test_e2e.py index de477223..dcb51a98 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -503,24 +503,31 @@ def test_full_cli_e2e(self) -> None: _step(37, "sharing list / lineage show", "read-only checks") self._test_sharing_and_lineage() + # ============================================================== + # PHASE 12.5: Kai (Keboola AI Assistant) + # ============================================================== + + _step(38, "kai ping / ask / history", "Keboola AI Assistant") + self._test_kai_commands() + # ============================================================== # PHASE 13: Job commands (expanded) # ============================================================== - _step(38, "job list + detail", "verify job listing structure") + _step(39, "job list + detail", "verify job listing structure") self._test_job_commands() # ============================================================== # PHASE 14: Cleanup # ============================================================== - _step(39, "config delete", "cleanup config via CLI") + _step(40, "config delete", "cleanup config via CLI") self._test_config_delete(config_id) - _step(40, "storage delete-table + delete-bucket", "CLI-driven cleanup") + _step(41, "storage delete-table + delete-bucket", "CLI-driven cleanup") self._test_storage_cleanup(bucket_id, table_id) - _step(41, "project edit + remove", "final cleanup") + _step(42, "project edit + remove", "final cleanup") self._test_project_edit_and_remove() print("\n" + "=" * 60) @@ -1756,6 +1763,56 @@ def _test_sharing_and_lineage(self) -> None: # Lineage may be empty on a single-project setup assert data["status"] == "ok" + def _test_kai_commands(self) -> None: + """Test Kai AI Assistant commands (gracefully skip if not available).""" + # kai ping — check if Kai is available for this project + result = self._run("kai", "ping", "--project", self.alias) + if result.exit_code != 0: + output = result.output + if "KAI_NOT_ENABLED" in output or "KAI_ERROR" in output: + print( + f" {_YELLOW}SKIP: Kai not available for this project " + f"(exit {result.exit_code}){_RESET}" + ) + return + # Unexpected error — fail the test + assert result.exit_code == 0, f"kai ping failed unexpectedly: {result.output}" + + # Ping succeeded — verify structure + ping_data = json.loads(result.output) + assert ping_data["status"] == "ok" + assert "timestamp" in ping_data["data"] + assert "mcp_status" in ping_data["data"] + + # kai ask — one-shot question + result = self._run( + "kai", + "ask", + "--project", + self.alias, + "-m", + "Reply with just the word OK", + ) + if result.exit_code != 0: + # Auth issue (e.g. token type) — skip remaining kai tests + print( + f" {_YELLOW}SKIP: kai ask failed " + f"(exit {result.exit_code}), skipping chat/history{_RESET}" + ) + return + + ask_data = json.loads(result.output) + assert ask_data["status"] == "ok" + assert "response" in ask_data["data"] + assert "chat_id" in ask_data["data"] + assert len(ask_data["data"]["response"]) > 0 + + # kai history — list recent chats (at least the one we just created) + data = self._run_ok("kai", "history", "--project", self.alias, "--limit", "5") + assert "chats" in data["data"] + # We just chatted, so there should be at least 1 + assert len(data["data"]["chats"]) >= 1 + def _test_job_commands(self) -> None: """Verify job listing structure and detail (if jobs exist).""" # job list From 83f628163ef786a2b8053bb7c869d6c2daf8fa0e Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 14 Apr 2026 17:26:30 +0200 Subject: [PATCH 3/3] feat: mark kai as BETA, add context + SKILL reference - Mark kai command group as (BETA) in --help output - Add Kai section to kbagent context command output - Add references/kai-workflow.md to plugin SKILL --- .../skills/kbagent/references/kai-workflow.md | 72 +++++++++++++++++++ src/keboola_agent_cli/commands/context.py | 18 +++++ src/keboola_agent_cli/commands/kai.py | 2 +- 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 plugins/kbagent/skills/kbagent/references/kai-workflow.md diff --git a/plugins/kbagent/skills/kbagent/references/kai-workflow.md b/plugins/kbagent/skills/kbagent/references/kai-workflow.md new file mode 100644 index 00000000..3fc53a32 --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/kai-workflow.md @@ -0,0 +1,72 @@ +# Kai (Keboola AI Assistant) Workflow + +Kai is Keboola's cloud AI assistant with MCP access to project data. +kbagent bridges Claude Code (local) to Kai (cloud) for Keboola-specific questions. + +> **BETA**: Kai commands require a project with the `agent-chat` feature enabled. +> Token authentication requirements are being finalized. + +## When to use Kai vs local tools + +| Situation | Use | +|-----------|-----| +| Need project-specific context (tables, configs, lineage) | `kbagent kai ask` | +| Simple data listing (buckets, tables, configs) | `kbagent config list`, `kbagent storage tables` | +| Need Keboola domain knowledge (component behavior, best practices) | `kbagent kai ask` | +| Need to modify data (upload, create, delete) | Direct CLI commands | + +## Quick start + +```bash +# Check if Kai is available +kbagent kai ping --project my-project + +# Ask a question about the project +kbagent kai ask --project my-project -m "What tables do I have?" + +# Multi-turn conversation +kbagent kai chat --project my-project -m "Help me debug my pipeline" +# Note the chat_id in the response, then continue: +kbagent kai chat --project my-project --chat-id CHAT_ID -m "What about the error in step 3?" + +# View chat history +kbagent kai history --project my-project --limit 10 +``` + +## Feature detection + +Kai requires the `agent-chat` feature flag on the project. If not enabled, +kai commands return error code `KAI_NOT_ENABLED` with a clear message. + +Check via: `kbagent --json kai ping --project ALIAS` — exit code 0 means Kai is available. + +## JSON output + +All kai commands support `--json` for structured output: + +```bash +# Ping +kbagent --json kai ping --project my-project +# {"status": "ok", "data": {"timestamp": "...", "mcp_status": "ok", ...}} + +# Ask +kbagent --json kai ask --project my-project -m "How many tables?" +# {"status": "ok", "data": {"chat_id": "uuid", "response": "You have 19 tables."}} + +# History +kbagent --json kai history --project my-project +# {"status": "ok", "data": {"chats": [...], "has_more": false}} +``` + +## Common patterns for Claude Code + +```bash +# Use kai ask as a Keboola knowledge oracle +kbagent --json kai ask --project prod -m "Is it safe to drop bucket in.c-legacy?" + +# Get project overview for onboarding +kbagent --json kai ask --project prod -m "Describe the data flow in this project" + +# Debug a failed job +kbagent --json kai ask --project prod -m "Why did job 12345 fail?" +``` diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 53b1175a..955592f9 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -312,6 +312,24 @@ --input accepts: inline JSON, @file.json (from file), or - (from stdin). --branch is a CLI flag (NOT a tool input param). Do not pass branch_id in --input. +### Kai -- Keboola AI Assistant (BETA) + + kbagent kai ping [--project NAME] + Check Kai server health and MCP connection status. + Fails with KAI_NOT_ENABLED if the project lacks the 'agent-chat' feature. + + kbagent kai ask --message "question" [--project NAME] + One-shot question to Kai. Collects full response. Use --json for structured output. + Kai has MCP access to project data -- use for Keboola-specific questions + (e.g. "What tables do I have?", "Is it safe to drop bucket X?"). + + kbagent kai chat --message "msg" [--chat-id ID] [--project NAME] + Send message in a chat session. Use --chat-id to continue a conversation. + Without --chat-id starts a new chat. Returns chat_id for continuation. + + kbagent kai history [--project NAME] [--limit N] + List recent Kai chat sessions. Default limit: 10. + ### Utility Commands kbagent init [--from-global] diff --git a/src/keboola_agent_cli/commands/kai.py b/src/keboola_agent_cli/commands/kai.py index ec5be638..809cf189 100644 --- a/src/keboola_agent_cli/commands/kai.py +++ b/src/keboola_agent_cli/commands/kai.py @@ -9,7 +9,7 @@ from ..errors import ConfigError, KeboolaApiError from ._helpers import check_cli_permission, get_formatter, get_service, map_error_to_exit_code -kai_app = typer.Typer(help="Keboola AI Assistant (Kai) — ask questions about your project") +kai_app = typer.Typer(help="(BETA) Keboola AI Assistant (Kai) — ask questions about your project") @kai_app.callback(invoke_without_command=True)