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
42 changes: 42 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so

# Distribution / packaging
dist/
build/
*.egg-info/
*.egg

# Virtual environments
.venv/
venv/
ENV/

# Environment variables
.env
.env.local

# IDE
.idea/
.vscode/
*.swp
*.swo
*~

# Testing
.pytest_cache/
.coverage
htmlcov/

# mypy
.mypy_cache/

# ruff
.ruff_cache/

# OS
.DS_Store
Thumbs.db
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Keboola Agent CLI

AI-friendly CLI for managing Keboola projects.
37 changes: 37 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[project]
name = "keboola-agent-cli"
version = "0.1.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
{ name = "Keboola", email = "dev@keboola.com" },
]
dependencies = [
"typer[all]>=0.12",
"rich>=13",
"httpx>=0.27",
"pydantic>=2.5",
"platformdirs>=4",
]

[project.scripts]
kbagent = "keboola_agent_cli.cli:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/keboola_agent_cli"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

[dependency-groups]
dev = [
"pytest>=8",
"pytest-httpx>=0.30",
]
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Keboola Agent CLI - AI-friendly interface to Keboola projects."""

__version__ = "0.1.0"
5 changes: 5 additions & 0 deletions src/keboola_agent_cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Allow running as `python -m keboola_agent_cli`."""

from .cli import app

app()
61 changes: 61 additions & 0 deletions src/keboola_agent_cli/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Typer root application with global options and subcommand registration."""

import sys
from typing import Optional

import typer

from .commands.config import config_app
from .commands.context import context_command
from .commands.doctor import doctor_command
from .commands.project import project_app
from .output import OutputFormatter

app = typer.Typer(
name="kbagent",
help="Keboola Agent CLI -- AI-friendly interface to Keboola projects",
no_args_is_help=True,
)

app.add_typer(project_app, name="project")
app.add_typer(config_app, name="config")
app.command("context")(context_command)
app.command("doctor")(doctor_command)


@app.callback()
def main(
ctx: typer.Context,
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output in JSON format (for machine consumption)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Enable verbose output",
),
no_color: bool = typer.Option(
False,
"--no-color",
help="Disable colored output",
),
) -> None:
"""Global options applied to all commands."""
is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
effective_no_color = no_color or not is_tty

formatter = OutputFormatter(
json_mode=json_output,
no_color=effective_no_color,
verbose=verbose,
)

ctx.ensure_object(dict)
ctx.obj["formatter"] = formatter
ctx.obj["json_output"] = json_output
ctx.obj["verbose"] = verbose
ctx.obj["no_color"] = effective_no_color
Empty file.
42 changes: 42 additions & 0 deletions src/keboola_agent_cli/commands/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Configuration browsing commands - list and detail."""

from typing import Optional

import typer

from ..output import OutputFormatter

config_app = typer.Typer(help="Browse and inspect configurations")


def _get_formatter(ctx: typer.Context) -> OutputFormatter:
"""Retrieve the OutputFormatter from the Typer context."""
return ctx.obj["formatter"]


@config_app.command("list")
def config_list(
ctx: typer.Context,
project: Optional[list[str]] = typer.Option(None, "--project", help="Project alias (can be repeated)"),
component_type: Optional[str] = typer.Option(
None,
"--component-type",
help="Filter by component type: extractor, writer, transformation, application",
),
component_id: Optional[str] = typer.Option(None, "--component-id", help="Filter by specific component ID"),
) -> None:
"""List configurations from connected projects."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))


@config_app.command("detail")
def config_detail(
ctx: typer.Context,
project: str = typer.Option(..., "--project", help="Project alias"),
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
) -> None:
"""Show detailed information about a specific configuration."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))
16 changes: 16 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Context command - provides usage instructions for AI agents."""

import typer

from ..output import OutputFormatter


def _get_formatter(ctx: typer.Context) -> OutputFormatter:
"""Retrieve the OutputFormatter from the Typer context."""
return ctx.obj["formatter"]


def context_command(ctx: typer.Context) -> None:
"""Show usage instructions for AI agents interacting with Keboola."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))
16 changes: 16 additions & 0 deletions src/keboola_agent_cli/commands/doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Doctor command - health check for CLI configuration and connectivity."""

import typer

from ..output import OutputFormatter


def _get_formatter(ctx: typer.Context) -> OutputFormatter:
"""Retrieve the OutputFormatter from the Typer context."""
return ctx.obj["formatter"]


def doctor_command(ctx: typer.Context) -> None:
"""Run health checks on CLI configuration and project connectivity."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))
68 changes: 68 additions & 0 deletions src/keboola_agent_cli/commands/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Project management commands - add, list, remove, edit, status."""

from typing import Optional

import typer

from ..output import OutputFormatter

project_app = typer.Typer(help="Manage connected Keboola projects")


def _get_formatter(ctx: typer.Context) -> OutputFormatter:
"""Retrieve the OutputFormatter from the Typer context."""
return ctx.obj["formatter"]


@project_app.command("add")
def project_add(
ctx: typer.Context,
alias: str = typer.Option(..., help="Human-friendly name for this project"),
url: str = typer.Option(
"https://connection.keboola.com",
help="Keboola stack URL",
),
token: str = typer.Option(..., help="Storage API token"),
) -> None:
"""Add a new Keboola project connection."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))


@project_app.command("list")
def project_list(ctx: typer.Context) -> None:
"""List all connected Keboola projects."""
formatter = _get_formatter(ctx)
formatter.output([], lambda c, d: c.print("Not yet implemented"))


@project_app.command("remove")
def project_remove(
ctx: typer.Context,
alias: str = typer.Option(..., help="Alias of the project to remove"),
) -> None:
"""Remove a Keboola project connection."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))


@project_app.command("edit")
def project_edit(
ctx: typer.Context,
alias: str = typer.Option(..., help="Alias of the project to edit"),
url: Optional[str] = typer.Option(None, help="New Keboola stack URL"),
token: Optional[str] = typer.Option(None, help="New Storage API token"),
) -> None:
"""Edit an existing Keboola project connection."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))


@project_app.command("status")
def project_status(
ctx: typer.Context,
project: Optional[str] = typer.Option(None, "--project", help="Check only this project (default: all)"),
) -> None:
"""Test connectivity to connected Keboola projects."""
formatter = _get_formatter(ctx)
formatter.output("Not yet implemented", lambda c, d: c.print(d))
82 changes: 82 additions & 0 deletions src/keboola_agent_cli/config_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Persistent configuration store for Keboola Agent CLI.

Manages reading and writing of config.json with project connections.
"""

from pathlib import Path

import platformdirs

from .models import AppConfig, ProjectConfig


class ConfigStore:
"""Handles persistence of application configuration to disk.

Configuration is stored as JSON at the platform-appropriate config directory,
defaulting to ~/.config/keboola-agent-cli/config.json on Linux/macOS.
"""

CONFIG_FILENAME = "config.json"

def __init__(self, config_dir: Path | None = None) -> None:
if config_dir is None:
self._config_dir = Path(platformdirs.user_config_dir("keboola-agent-cli"))
else:
self._config_dir = config_dir
self._config_path = self._config_dir / self.CONFIG_FILENAME

@property
def config_path(self) -> Path:
"""Return the path to the config file."""
return self._config_path

def load(self) -> AppConfig:
"""Load configuration from disk.

Returns an empty AppConfig if the file does not exist.
"""
if not self._config_path.exists():
return AppConfig()
raw = self._config_path.read_text(encoding="utf-8")
return AppConfig.model_validate_json(raw)

def save(self, config: AppConfig) -> None:
"""Save configuration to disk with secure file permissions (0600)."""
self._config_dir.mkdir(parents=True, exist_ok=True)
json_str = config.model_dump_json(indent=2)
self._config_path.write_text(json_str + "\n", encoding="utf-8")
self._config_path.chmod(0o600)

def add_project(self, alias: str, project: ProjectConfig) -> None:
"""Add a project to the configuration."""
config = self.load()
config.projects[alias] = project
if not config.default_project:
config.default_project = alias
self.save(config)

def remove_project(self, alias: str) -> None:
"""Remove a project from the configuration."""
config = self.load()
config.projects.pop(alias, None)
if config.default_project == alias:
config.default_project = next(iter(config.projects), "")
self.save(config)

def get_project(self, alias: str) -> ProjectConfig | None:
"""Get a project by alias, or None if not found."""
config = self.load()
return config.projects.get(alias)

def edit_project(self, alias: str, **kwargs: str | int) -> None:
"""Update fields on an existing project."""
config = self.load()
if alias not in config.projects:
return
project = config.projects[alias]
for key, value in kwargs.items():
if hasattr(project, key) and value is not None:
setattr(project, key, value)
config.projects[alias] = project
self.save(config)
Loading