Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.65.1",
"version": "0.66.0",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.65.1",
"version": "0.66.0",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.65.1"
version = "0.66.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
9 changes: 9 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.66.0": [
"New: bulk-remove projects from the `kbagent serve` Web UI. The Projects table now has "
"per-row checkboxes plus a select-all header, and a `Remove from kbagent` action that "
"unregisters several projects at once. A styled confirmation modal lists the affected "
"aliases and makes clear this only edits the local kbagent config -- it does NOT delete "
"the Keboola projects. Backed by a new `POST /projects/bulk-delete` REST endpoint "
"(`ProjectService.bulk_remove_projects`) with per-alias error accumulation and a "
"`dry_run` mode; one bad alias never blocks the rest.",
],
"0.65.1": [
"BREAKING: Removed `data-app git-bind-credential` (and its `kbagent serve` endpoint). It shipped in "
"0.65.0 on a misdiagnosis: managed-repo deploys were failing and we believed the platform "
Expand Down
19 changes: 18 additions & 1 deletion src/keboola_agent_cli/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,22 @@ def add_project(self, alias: str, project: ProjectConfig) -> None:
config.default_project = alias
self.save(config)

def ensure_removable(self, alias: str) -> None:
"""Validate that ``alias`` can be removed, without mutating anything.

Raises the same errors a real ``remove_project`` would: ``ConfigError``
if the alias does not exist, or if it is an ephemeral ``__env__``
project synthesized from the environment. Shared by the live remove and
the bulk dry-run preview so both paths stay in sync (read-only).

Raises:
ConfigError: If the alias is missing or ephemeral.
"""
config = self.load()
if alias not in config.projects:
raise ConfigError(f"Project '{alias}' not found.")
self._reject_ephemeral_mutation(config, alias, "removed")

def remove_project(self, alias: str) -> None:
"""Remove a project from the configuration.

Expand All @@ -403,7 +419,8 @@ def remove_project(self, alias: str) -> None:
alias: The project alias to remove.

Raises:
ConfigError: If the alias does not exist.
ConfigError: If the alias does not exist, or is an ephemeral
``__env__`` project.
"""
config = self.load()
if alias not in config.projects:
Expand Down
18 changes: 18 additions & 0 deletions src/keboola_agent_cli/server/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ class ProjectDescription(BaseModel):
description: str


class ProjectBulkDelete(BaseModel):
aliases: list[str]
dry_run: bool = False


@router.get("", summary="List registered projects")
def list_projects(registry: ServiceRegistry = Depends(get_registry)) -> dict[str, Any]:
"""All registered project aliases."""
Expand All @@ -43,6 +48,19 @@ def add_project(
return registry.project.add_project(body.alias, body.stack_url, body.token)


@router.post("/bulk-delete", summary="Remove multiple projects")
def bulk_delete_projects(
body: ProjectBulkDelete, registry: ServiceRegistry = Depends(get_registry)
) -> dict[str, Any]:
"""Remove several projects at once, accumulating per-alias errors.

Returns ``{removed, failed, dry_run}``; one bad alias does not block the
rest. Mirrors `kbagent project remove` applied to a list. Declared before
the ``/{alias}`` routes so the literal path is matched first.
"""
return registry.project.bulk_remove_projects(aliases=body.aliases, dry_run=body.dry_run)


@router.delete("/{alias}", summary="Remove a project")
def remove_project(alias: str, registry: ServiceRegistry = Depends(get_registry)) -> dict[str, Any]:
"""Remove a project by alias. Mirrors `kbagent project remove`."""
Expand Down
50 changes: 50 additions & 0 deletions src/keboola_agent_cli/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,56 @@ def remove_project(self, alias: str) -> dict[str, str]:
self._config_store.remove_project(alias)
return {"alias": alias, "message": f"Project '{alias}' removed."}

def bulk_remove_projects(
self,
aliases: list[str],
dry_run: bool = False,
) -> dict[str, Any]:
"""Remove several projects in one call, accumulating per-alias errors.

One failing alias (e.g. it does not exist, or is an ephemeral
``__env__`` project that cannot be removed) does not stop the others --
the failure is recorded under ``failed`` and the rest proceed. Like the
single-remove path this only edits ``config.json`` locally; no remote
API call is made.

Args:
aliases: Project aliases to remove. Duplicates are de-duplicated
while preserving order.
dry_run: When True, validate each alias and report what WOULD be
removed without mutating ``config.json``.

Returns:
``{"removed": [...], "failed": [{"alias", "error"}], "dry_run": bool}``
where ``removed`` lists the aliases removed (or that would be
removed in dry-run mode).
"""
seen: set[str] = set()
ordered: list[str] = []
for alias in aliases:
if alias not in seen:
seen.add(alias)
ordered.append(alias)

removed: list[str] = []
failed: list[dict[str, str]] = []
for alias in ordered:
try:
if dry_run:
# Apply the SAME validation as the live remove (missing
# alias + ephemeral `__env__` guard) without mutating, so a
# dry-run never reports an alias as removable that the real
# run would reject.
self._config_store.ensure_removable(alias)
removed.append(alias)
else:
self._config_store.remove_project(alias)
removed.append(alias)
except ConfigError as exc:
failed.append({"alias": alias, "error": exc.message})

return {"removed": removed, "failed": failed, "dry_run": dry_run}

def edit_project(
self,
alias: str,
Expand Down
70 changes: 70 additions & 0 deletions tests/test_server_router_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,3 +852,73 @@ def test_data_app_runs_endpoint_calls_service(tmp_path: Path) -> None:

assert res.status_code == 200, res.text
data_app_svc.list_app_runs.assert_called_once_with(PROJECT, APP_ID, limit=3)


# ---------------------------------------------------------------------------
# projects.py POST /projects/bulk-delete
# Service: project.bulk_remove_projects(aliases=..., dry_run=...)
# ---------------------------------------------------------------------------


def test_bulk_delete_projects_passes_aliases(tmp_path: Path) -> None:
"""POST /projects/bulk-delete must call ProjectService.bulk_remove_projects."""
project_svc = MagicMock()
project_svc.bulk_remove_projects.return_value = {
"removed": ["a", "b"],
"failed": [],
"dry_run": False,
}
registry = _mock_registry(project=project_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post(
"/projects/bulk-delete",
headers=AUTH,
json={"aliases": ["a", "b"]},
)

assert res.status_code == 200, res.text
assert res.json()["removed"] == ["a", "b"]
project_svc.bulk_remove_projects.assert_called_once_with(aliases=["a", "b"], dry_run=False)


def test_bulk_delete_projects_forwards_dry_run(tmp_path: Path) -> None:
"""The dry_run flag in the body must reach the service."""
project_svc = MagicMock()
project_svc.bulk_remove_projects.return_value = {
"removed": ["a"],
"failed": [],
"dry_run": True,
}
registry = _mock_registry(project=project_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post(
"/projects/bulk-delete",
headers=AUTH,
json={"aliases": ["a"], "dry_run": True},
)

assert res.status_code == 200, res.text
project_svc.bulk_remove_projects.assert_called_once_with(aliases=["a"], dry_run=True)


def test_bulk_delete_route_not_shadowed_by_alias_delete(tmp_path: Path) -> None:
"""The literal /projects/bulk-delete POST must not be swallowed by /{alias}.

A DELETE /{alias} exists; the bulk route is a POST to a literal path, so it
must resolve to bulk_remove_projects, never remove_project('bulk-delete').
"""
project_svc = MagicMock()
project_svc.bulk_remove_projects.return_value = {"removed": [], "failed": [], "dry_run": False}
registry = _mock_registry(project=project_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post("/projects/bulk-delete", headers=AUTH, json={"aliases": []})

assert res.status_code == 200, res.text
project_svc.bulk_remove_projects.assert_called_once()
project_svc.remove_project.assert_not_called()
99 changes: 99 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,105 @@ def test_remove_nonexistent_raises_error(self, tmp_config_dir: Path) -> None:
service.remove_project("nonexistent")


class TestBulkRemoveProjects:
"""Tests for ProjectService.bulk_remove_projects()."""

@staticmethod
def _service_with_projects(tmp_config_dir: Path, *aliases: str) -> ProjectService:
store = ConfigStore(config_dir=tmp_config_dir)
mock_client = make_mock_client()
service = ProjectService(
config_store=store,
client_factory=lambda url, token: mock_client,
)
for alias in aliases:
service.add_project(
alias=alias,
stack_url="https://connection.keboola.com",
token="901-55555-fakeTestTokenDoNotUseXXXXXXXX",
)
return service

def test_removes_all_requested(self, tmp_config_dir: Path) -> None:
service = self._service_with_projects(tmp_config_dir, "a", "b", "c")

result = service.bulk_remove_projects(["a", "c"])

assert result["removed"] == ["a", "c"]
assert result["failed"] == []
assert result["dry_run"] is False
assert service._config_store.get_project("a") is None
assert service._config_store.get_project("c") is None
# Untouched alias survives.
assert service._config_store.get_project("b") is not None

def test_partial_failure_accumulates(self, tmp_config_dir: Path) -> None:
"""A missing alias is recorded under failed; the rest still go."""
service = self._service_with_projects(tmp_config_dir, "a", "b")

result = service.bulk_remove_projects(["a", "ghost", "b"])

assert result["removed"] == ["a", "b"]
assert len(result["failed"]) == 1
assert result["failed"][0]["alias"] == "ghost"
assert "not found" in result["failed"][0]["error"].lower()
assert service._config_store.get_project("a") is None
assert service._config_store.get_project("b") is None

def test_dry_run_does_not_mutate(self, tmp_config_dir: Path) -> None:
service = self._service_with_projects(tmp_config_dir, "a", "b")

result = service.bulk_remove_projects(["a", "b"], dry_run=True)

assert result["dry_run"] is True
assert result["removed"] == ["a", "b"]
assert result["failed"] == []
# Nothing actually removed.
assert service._config_store.get_project("a") is not None
assert service._config_store.get_project("b") is not None

def test_dry_run_reports_missing_as_failed(self, tmp_config_dir: Path) -> None:
service = self._service_with_projects(tmp_config_dir, "a")

result = service.bulk_remove_projects(["a", "ghost"], dry_run=True)

assert result["removed"] == ["a"]
assert [f["alias"] for f in result["failed"]] == ["ghost"]
assert service._config_store.get_project("a") is not None

def test_duplicate_aliases_deduplicated(self, tmp_config_dir: Path) -> None:
service = self._service_with_projects(tmp_config_dir, "a")

result = service.bulk_remove_projects(["a", "a"])

assert result["removed"] == ["a"]
assert result["failed"] == []

def test_dry_run_rejects_ephemeral_env_project(
self, tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A dry-run must reject an ephemeral `__env__` project, matching the
live remove path (which raises ConfigError). Regression for the
dry-run-skipped-the-ephemeral-guard bug flagged in review."""
monkeypatch.setenv("KBAGENT_PROJECT_FROM_ENV", "1")
monkeypatch.setenv("KBC_TOKEN", "901-99999-fakeHeadlessTokenDoNotUseXXXXX")
monkeypatch.setenv("KBC_STORAGE_API_URL", "https://connection.keboola.com")
store = ConfigStore(config_dir=tmp_config_dir)
service = ProjectService(
config_store=store,
client_factory=lambda _u, _t: make_mock_client(),
)
# The env-synthesized project exists in memory...
assert store.get_project("__env__") is not None

result = service.bulk_remove_projects(["__env__"], dry_run=True)

# ...but must not be reported as removable.
assert result["removed"] == []
assert [f["alias"] for f in result["failed"]] == ["__env__"]
assert "environment" in result["failed"][0]["error"].lower()


class TestEditProject:
"""Tests for ProjectService.edit_project()."""

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading