diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 9b1d405f..4fa817d0 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -233,6 +233,41 @@ def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any] ) return response.json() + def list_config_folder_metadata(self, branch_id: int) -> dict[str, str]: + """Fetch folder names for all configurations via metadata search. + + Uses the search/component-configurations endpoint to find configs + with ``KBC.configuration.folderName`` metadata. + + Note: This endpoint requires a branch ID (branch-only route). + + Args: + branch_id: Branch ID (required — use default branch for production). + + Returns: + Dict mapping ``"{component_id}/{config_id}"`` to folder name. + """ + prefix = f"/v2/storage/branch/{branch_id}" + resp = self._request( + "GET", + f"{prefix}/search/component-configurations", + params={ + "metadataKeys[]": "KBC.configuration.folderName", + "include": "filteredMetadata", + }, + ) + folder_map: dict[str, str] = {} + for item in resp.json(): + comp_id = item.get("idComponent", "") + config_id = str(item.get("configurationId", "")) + meta = next( + (m for m in item.get("metadata", []) if m["key"] == "KBC.configuration.folderName"), + None, + ) + if meta: + folder_map[f"{comp_id}/{config_id}"] = meta["value"] + return folder_map + def create_config( self, component_id: str, diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index 29aceafe..347aac14 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -163,6 +163,7 @@ def format_configs_table(console: Console, data: dict[str, Any]) -> None: table.add_column("Type", style="dim") table.add_column("Config ID", justify="right") table.add_column("Config Name") + table.add_column("Folder", style="dim") table.add_column("Last Modified", style="dim") table.add_column("Modified By", style="dim") @@ -177,6 +178,7 @@ def format_configs_table(console: Console, data: dict[str, Any]) -> None: cfg["component_type"], cfg["config_id"], cfg["config_name"], + cfg.get("folder", ""), last_mod, cfg.get("last_modified_by", ""), ) diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 0079cda5..5f381016 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -64,6 +64,24 @@ def _fetch_project_configs( client = self._client_factory(project.stack_url, project.token) try: components = client.list_components(component_type=component_type) + + # Fetch folder metadata (requires branch ID — search endpoint is branch-only) + folder_map: dict[str, str] = {} + try: + # Use active branch or find the default branch ID + branch_id = project.active_branch_id + if not branch_id: + # Fetch default branch ID from dev-branches endpoint + branches = client.list_dev_branches() + default = next((b for b in branches if b.get("isDefault")), None) + if default: + branch_id = default["id"] + if branch_id: + result = client.list_config_folder_metadata(branch_id=branch_id) + folder_map = result if isinstance(result, dict) else {} + except Exception: + pass # graceful fallback if search endpoint unavailable + configs: list[dict[str, Any]] = [] for component in components: comp_id = component.get("id", "") @@ -79,6 +97,7 @@ def _fetch_project_configs( # Extract last-modified info from currentVersion current_version = cfg.get("currentVersion", {}) creator_token = current_version.get("creatorToken", {}) + cfg_id = str(cfg.get("id", "")) configs.append( { @@ -86,12 +105,13 @@ def _fetch_project_configs( "component_id": comp_id, "component_name": comp_name, "component_type": comp_type, - "config_id": str(cfg.get("id", "")), + "config_id": cfg_id, "config_name": cfg.get("name", ""), "config_description": cfg.get("description", ""), "last_modified": current_version.get("created", ""), "last_modified_by": creator_token.get("description", ""), "last_change_description": current_version.get("changeDescription", ""), + "folder": folder_map.get(f"{comp_id}/{cfg_id}", ""), } ) return (alias, configs, True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e3fd45e..90af5717 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -727,9 +727,9 @@ def test_config_list_human_output(self, tmp_path: Path) -> None: # Should show project-grouped table assert "prod" in result.output assert "Configurations" in result.output - # Rich may truncate long names in narrow terminals, check for prefix - assert "Production" in result.output - assert "keboola.ex" in result.output + # Rich may truncate long names in narrow terminals + assert "Product" in result.output + assert "keboola" in result.output def test_config_list_project_filter(self, tmp_path: Path) -> None: """config list --project X returns configs only from that project.""" @@ -1065,8 +1065,8 @@ def factory(url, token): assert result.exit_code == 0 # Should show configs from good project assert "Configurations" in result.output - # Rich may truncate long names in narrow terminals, check for prefix - assert "Production" in result.output + # Rich may truncate long names in narrow terminals + assert "Product" in result.output # Should show warning about bad project assert "bad" in result.output assert "Token expired" in result.output diff --git a/tests/test_output.py b/tests/test_output.py index ebea69e1..53ae8f99 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -891,9 +891,9 @@ def test_configs_table_grouped_by_project(self) -> None: assert "Configurations" in output assert "prod" in output - # Rich may word-wrap long names across lines in narrow console - assert "Production" in output - assert "Write to" in output + # Rich may truncate long names in narrow console (7 columns) + assert "Product" in output + assert "Write" in output def test_configs_table_empty(self) -> None: """format_configs_table shows helpful message when no configs found."""