Skip to content
Merged
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
3 changes: 3 additions & 0 deletions plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ This prints all commands, flags, workflows, and tips. Read it fully before proce
| Load tables into a workspace | `kbagent workspace load --project PROJECT --workspace-id WORKSPACE-ID --tables TABLES` |
| Execute SQL query in a workspace via Query Service | `kbagent workspace query --project PROJECT --workspace-id WORKSPACE-ID` |
| Create a workspace from a transformation config | `kbagent workspace from-transformation --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` |
| List storage buckets with sharing/linked bucket information | `kbagent storage buckets` |
| Show detailed bucket info including Snowflake direct access paths | `kbagent storage bucket-detail --project PROJECT --bucket-id BUCKET-ID` |
| List storage tables from a project | `kbagent storage tables --project PROJECT` |
| Initialize a sync working directory for a Keboola project | `kbagent sync init --project PROJECT` |
| Download all configurations from a Keboola project to local files | `kbagent sync pull --project PROJECT` |
| Show which local configurations have been modified, added, or deleted | `kbagent sync status` |
Expand Down
5 changes: 5 additions & 0 deletions src/keboola_agent_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .commands.org import org_app
from .commands.project import project_app
from .commands.repl import repl_command
from .commands.storage import storage_app
from .commands.sync import sync_app
from .commands.tool import tool_app
from .commands.version import version_command
Expand All @@ -33,6 +34,7 @@
from .services.mcp_service import McpService
from .services.org_service import OrgService
from .services.project_service import ProjectService
from .services.storage_service import StorageService
from .services.sync_service import SyncService
from .services.version_service import VersionService
from .services.workspace_service import WorkspaceService
Expand All @@ -53,6 +55,7 @@
app.add_typer(explorer_app, name="explorer")
app.add_typer(llm_app, name="llm")
app.add_typer(workspace_app, name="workspace")
app.add_typer(storage_app, name="storage")
app.add_typer(sync_app, name="sync")
app.command("context")(context_command)
app.command("doctor")(doctor_command)
Expand Down Expand Up @@ -128,6 +131,7 @@ def main(
org_service = OrgService(config_store=config_store)
mcp_service = McpService(config_store=config_store)
branch_service = BranchService(config_store=config_store)
storage_service = StorageService(config_store=config_store)
sync_service = SyncService(config_store=config_store)
workspace_service = WorkspaceService(config_store=config_store)
kbc_service = KbcService(config_store=config_store)
Expand All @@ -153,6 +157,7 @@ def main(
ctx.obj["org_service"] = org_service
ctx.obj["mcp_service"] = mcp_service
ctx.obj["branch_service"] = branch_service
ctx.obj["storage_service"] = storage_service
ctx.obj["sync_service"] = sync_service
ctx.obj["workspace_service"] = workspace_service
ctx.obj["kbc_service"] = kbc_service
Expand Down
49 changes: 49 additions & 0 deletions src/keboola_agent_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,55 @@ def list_buckets(self, include: str | None = None) -> list[dict[str, Any]]:
response = self._request("GET", "/v2/storage/buckets", params=params)
return response.json()

def get_bucket_detail(
self,
bucket_id: str,
branch_id: int | None = None,
) -> dict[str, Any]:
"""Get detailed information about a storage bucket.

Returns full bucket metadata including sharing/linked info
(sourceBucket, sourceTable with project references).

Args:
bucket_id: Bucket ID (e.g. 'in.c-db').
branch_id: If set, target a specific dev branch.

Returns:
Bucket detail dict from the API.
"""
prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage"
safe_id = quote(bucket_id, safe="")
response = self._request("GET", f"{prefix}/buckets/{safe_id}")
return response.json()

def list_tables(
self,
bucket_id: str | None = None,
branch_id: int | None = None,
include: str | None = None,
) -> list[dict[str, Any]]:
"""List storage tables, optionally filtered by bucket.

Args:
bucket_id: If set, list tables only from this bucket.
branch_id: If set, target a specific dev branch.
include: Optional include parameter (e.g. 'columns').

Returns:
List of table dicts from the API.
"""
prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage"
params: dict[str, str] = {}
if include:
params["include"] = include
if bucket_id:
safe_id = quote(bucket_id, safe="")
response = self._request("GET", f"{prefix}/buckets/{safe_id}/tables", params=params)
else:
response = self._request("GET", f"{prefix}/tables", params=params)
return response.json()

def list_jobs(
self,
component_id: str | None = None,
Expand Down
39 changes: 39 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,32 @@
Example:
kbagent --json job detail --project prod --job-id 148512262

### Storage (Buckets and Tables)

kbagent storage buckets [--project NAME]
List storage buckets with sharing/linked bucket information.
Shows which buckets are linked from other projects, including the
source project ID and name. This info is NOT available via MCP tools.
Examples:
kbagent --json storage buckets
kbagent --json storage buckets --project prod

kbagent storage bucket-detail --project NAME --bucket-id BUCKET_ID
Show detailed bucket info including Snowflake direct access paths.
For linked/shared buckets, resolves the correct Snowflake database
and schema from the source project. Each table includes a ready-to-use
fully-qualified Snowflake path with proper quoting.
CRITICAL for direct Snowflake access: linked buckets live in a different
database than the current project (e.g. sapi_1507 instead of sapi_226).
Example:
kbagent --json storage bucket-detail --project slevomat --bucket-id in.c-db

kbagent storage tables --project NAME [--bucket-id BUCKET_ID]
List storage tables, optionally filtered by bucket.
Example:
kbagent --json storage tables --project prod
kbagent --json storage tables --project prod --bucket-id in.c-main

### Data Lineage

kbagent lineage [--project NAME]
Expand Down Expand Up @@ -558,6 +584,19 @@
kbagent --json workspace load --project prod --workspace-id WS_ID --tables in.c-bucket.my-table
kbagent --json workspace query --project prod --workspace-id WS_ID --sql "SELECT * FROM \"my-table\" LIMIT 10"

IMPORTANT -- Snowflake quoting rules for workspace queries:
Snowflake converts unquoted identifiers to UPPERCASE. If a database,
schema, or table name contains lowercase letters, dots, or hyphens,
you MUST double-quote it. This applies to ALL identifiers:
WRONG: SELECT * FROM sap_9.my_schema.my_table
(Snowflake reads this as SAP_9.MY_SCHEMA.MY_TABLE -- not found!)
RIGHT: SELECT * FROM "sap_9"."my_schema"."my_table"
Best practice: ALWAYS double-quote database, schema, and table names
in workspace queries, even if they look like they don't need it.
Keboola workspace database/schema names are often lowercase.
For shared/linked buckets, use 'kbagent storage bucket-detail' to get
the correct fully-qualified Snowflake path (source project DB differs).

16. Setting up projects -- two approaches:

a) Single project (you have a Storage API token):
Expand Down
208 changes: 208 additions & 0 deletions src/keboola_agent_cli/commands/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Storage commands - buckets, tables, and direct access path resolution.

Provides direct Storage API access including sharing/linked bucket metadata
that is not available via MCP tools.
"""

import typer

from ..errors import ConfigError, KeboolaApiError
from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code

storage_app = typer.Typer(help="Browse storage buckets and tables")


@storage_app.command("buckets")
def storage_buckets(
ctx: typer.Context,
project: list[str] | None = typer.Option(
None,
"--project",
help="Project alias (can be repeated for multiple projects)",
),
) -> None:
"""List storage buckets with sharing/linked bucket information.

Shows which buckets are linked from other projects, including the
source project ID and name. This information is not available via
MCP tools.
"""
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")

try:
result = service.list_buckets(aliases=project)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5) from None

if formatter.json_mode:
formatter.output(result)
else:
from rich.table import Table

buckets = result["buckets"]
if not buckets:
formatter.console.print("[dim]No buckets found.[/dim]")
return

# Group by project
by_project: dict[str, list[dict]] = {}
for b in buckets:
alias = b["project_alias"]
by_project.setdefault(alias, []).append(b)

for alias, proj_buckets in by_project.items():
table = Table(title=f"Buckets - {alias}")
table.add_column("Bucket ID", style="bold cyan")
table.add_column("Stage", style="dim")
table.add_column("Rows", justify="right")
table.add_column("Linked From", style="yellow")

for b in proj_buckets:
linked = ""
if b["is_linked"]:
linked = f"{b['source_project_name']} (#{b['source_project_id']})"
table.add_row(
b["id"],
b["stage"],
str(b["rows_count"]),
linked,
)

formatter.console.print(table)
formatter.console.print()

emit_project_warnings(formatter, result)


@storage_app.command("bucket-detail")
def storage_bucket_detail(
ctx: typer.Context,
project: str = typer.Option(
...,
"--project",
help="Project alias",
),
bucket_id: str = typer.Option(
...,
"--bucket-id",
help="Bucket ID (e.g. in.c-db)",
),
) -> None:
"""Show detailed bucket info including Snowflake direct access paths.

For linked/shared buckets, resolves the correct Snowflake database
and schema from the source project. Each table includes a ready-to-use
fully-qualified Snowflake path with proper quoting.
"""
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")

try:
result = service.get_bucket_detail(alias=project, bucket_id=bucket_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

if formatter.json_mode:
formatter.output(result)
else:
formatter.console.print(f"[bold]Bucket:[/bold] {result['bucket_id']}")
formatter.console.print(f" Display name: {result['display_name']}")
formatter.console.print(f" Backend: {result['backend']}")

if result["is_linked"]:
formatter.console.print(
f" [yellow]Linked from:[/yellow] "
f"{result['source_project_name']} (#{result['source_project_id']})"
)
formatter.console.print(f" Source bucket: {result['source_bucket_id']}")

formatter.console.print(f" Snowflake DB: {result['snowflake_database']}")
formatter.console.print(f" Snowflake schema: {result['snowflake_schema']}")
formatter.console.print(f" Tables: {result['table_count']}")

if result["tables"]:
formatter.console.print()
from rich.table import Table

table = Table(title="Tables with Snowflake paths")
table.add_column("Table", style="bold")
table.add_column("Snowflake Path", style="green")
table.add_column("Alias", style="dim")

for t in result["tables"][:50]: # limit display
table.add_row(
t["name"],
t["snowflake_path"],
"yes" if t["is_alias"] else "",
)

formatter.console.print(table)

if len(result["tables"]) > 50:
formatter.console.print(
f" ... and {len(result['tables']) - 50} more (use --json for full list)"
)


@storage_app.command("tables")
def storage_tables(
ctx: typer.Context,
project: str = typer.Option(
...,
"--project",
help="Project alias",
),
bucket_id: str | None = typer.Option(
None,
"--bucket-id",
help="Filter tables by bucket ID",
),
) -> None:
"""List storage tables from a project."""
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")

try:
result = service.list_tables(alias=project, bucket_id=bucket_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

if formatter.json_mode:
formatter.output(result)
else:
from rich.table import Table

tables = result["tables"]
if not tables:
formatter.console.print("[dim]No tables found.[/dim]")
return

table = Table(title=f"Tables - {result['project_alias']}")
table.add_column("Table ID", style="bold cyan")
table.add_column("Rows", justify="right")
table.add_column("Size", justify="right", style="dim")
table.add_column("Last Import", style="dim")

for t in tables:
size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0
last_import = t.get("last_import_date", "")
if last_import and "T" in last_import:
last_import = last_import.split("T")[0]
table.add_row(
t["id"],
str(t["rows_count"]),
f"{size_mb:.1f} MB",
last_import,
)

formatter.console.print(table)
Loading
Loading