commit 170413cf22cd24718400a66857fe361c2d1dd6a3 Author: MinaSaad1 Date: Thu Mar 26 13:05:53 2026 +0200 feat: initial pbi-cli project with all 20+ command groups Complete CLI framework for Power BI semantic models via MCP server: - Core: MCP client (stdio via mcp SDK), binary manager (VSIX download), config, connection store, dual output (JSON + Rich) - Commands: setup, connect, dax, measure, table, column, relationship, model, database, security-role, calc-group, partition, perspective, hierarchy, expression, calendar, trace, transaction, advanced - Binary resolution: env var > managed > VS Code extension fallback - Global --json flag for agent consumption diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3ca8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ + +# OS +.DS_Store +Thumbs.db + +# pbi-cli specific +~/.pbi-cli/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc9ec01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pbi-cli contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e97e84 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pbi-cli" +version = "0.1.0" +description = "CLI for Power BI semantic models - wraps the Power BI MCP server for token-efficient AI agent usage" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +authors = [ + {name = "pbi-cli contributors"}, +] +keywords = ["power-bi", "cli", "mcp", "semantic-model", "dax", "claude-code"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Database", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "click>=8.0.0", + "mcp>=1.20.0", + "rich>=13.0.0", + "httpx>=0.24.0", + "prompt-toolkit>=3.0.0", +] + +[project.scripts] +pbi = "pbi_cli.main:cli" + +[project.urls] +Homepage = "https://github.com/pbi-cli/pbi-cli" +Repository = "https://github.com/pbi-cli/pbi-cli" +Issues = "https://github.com/pbi-cli/pbi-cli/issues" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-asyncio>=0.21", + "ruff>=0.4.0", + "mypy>=1.10", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "e2e: end-to-end tests requiring real Power BI binary", +] + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/src/pbi_cli/__init__.py b/src/pbi_cli/__init__.py new file mode 100644 index 0000000..f143f61 --- /dev/null +++ b/src/pbi_cli/__init__.py @@ -0,0 +1,3 @@ +"""pbi-cli: CLI for Power BI semantic models via MCP server.""" + +__version__ = "0.1.0" diff --git a/src/pbi_cli/__main__.py b/src/pbi_cli/__main__.py new file mode 100644 index 0000000..d21a6e5 --- /dev/null +++ b/src/pbi_cli/__main__.py @@ -0,0 +1,6 @@ +"""Allow running pbi-cli as: python -m pbi_cli""" + +from pbi_cli.main import cli + +if __name__ == "__main__": + cli() diff --git a/src/pbi_cli/commands/__init__.py b/src/pbi_cli/commands/__init__.py new file mode 100644 index 0000000..0988fcf --- /dev/null +++ b/src/pbi_cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command groups for pbi-cli.""" diff --git a/src/pbi_cli/commands/_helpers.py b/src/pbi_cli/commands/_helpers.py new file mode 100644 index 0000000..74d57bb --- /dev/null +++ b/src/pbi_cli/commands/_helpers.py @@ -0,0 +1,48 @@ +"""Shared helpers for CLI commands to reduce boilerplate.""" + +from __future__ import annotations + +from typing import Any + +import click + +from pbi_cli.core.mcp_client import get_client +from pbi_cli.core.output import format_mcp_result, print_error +from pbi_cli.main import PbiContext + + +def run_tool( + ctx: PbiContext, + tool_name: str, + request: dict[str, Any], +) -> Any: + """Execute an MCP tool call with standard error handling. + + Adds connectionName from context if available. Formats output based + on --json flag. Returns the result or exits on error. + """ + if ctx.connection: + request.setdefault("connectionName", ctx.connection) + + client = get_client() + try: + result = client.call_tool(tool_name, request) + format_mcp_result(result, ctx.json_output) + return result + except Exception as e: + print_error(str(e)) + raise SystemExit(1) + finally: + client.stop() + + +def build_definition( + required: dict[str, Any], + optional: dict[str, Any], +) -> dict[str, Any]: + """Build a definition dict, including only non-None optional values.""" + definition = dict(required) + for key, value in optional.items(): + if value is not None: + definition[key] = value + return definition diff --git a/src/pbi_cli/commands/advanced.py b/src/pbi_cli/commands/advanced.py new file mode 100644 index 0000000..e5dfbf5 --- /dev/null +++ b/src/pbi_cli/commands/advanced.py @@ -0,0 +1,135 @@ +"""Less common operations: culture, translation, function, query-group.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def advanced() -> None: + """Advanced operations: cultures, translations, functions, query groups.""" + + +# --- Culture --- + +@advanced.group() +def culture() -> None: + """Manage model cultures (locales).""" + + +@culture.command(name="list") +@pass_context +def culture_list(ctx: PbiContext) -> None: + """List cultures.""" + run_tool(ctx, "culture_operations", {"operation": "List"}) + + +@culture.command() +@click.argument("name") +@pass_context +def culture_create(ctx: PbiContext, name: str) -> None: + """Create a culture.""" + run_tool(ctx, "culture_operations", {"operation": "Create", "definitions": [{"name": name}]}) + + +@culture.command(name="delete") +@click.argument("name") +@pass_context +def culture_delete(ctx: PbiContext, name: str) -> None: + """Delete a culture.""" + run_tool(ctx, "culture_operations", {"operation": "Delete", "name": name}) + + +# --- Translation --- + +@advanced.group() +def translation() -> None: + """Manage object translations.""" + + +@translation.command(name="list") +@click.option("--culture", "-c", required=True, help="Culture name.") +@pass_context +def translation_list(ctx: PbiContext, culture: str) -> None: + """List translations for a culture.""" + run_tool(ctx, "object_translation_operations", {"operation": "List", "cultureName": culture}) + + +@translation.command() +@click.option("--culture", "-c", required=True, help="Culture name.") +@click.option("--object-name", required=True, help="Object to translate.") +@click.option("--table", "-t", default=None, help="Table name (if translating table object).") +@click.option("--translated-caption", default=None, help="Translated caption.") +@click.option("--translated-description", default=None, help="Translated description.") +@pass_context +def create( + ctx: PbiContext, + culture: str, + object_name: str, + table: str | None, + translated_caption: str | None, + translated_description: str | None, +) -> None: + """Create an object translation.""" + definition = build_definition( + required={"objectName": object_name, "cultureName": culture}, + optional={ + "tableName": table, + "translatedCaption": translated_caption, + "translatedDescription": translated_description, + }, + ) + run_tool(ctx, "object_translation_operations", {"operation": "Create", "definitions": [definition]}) + + +# --- Function --- + +@advanced.group() +def function() -> None: + """Manage model functions.""" + + +@function.command(name="list") +@pass_context +def function_list(ctx: PbiContext) -> None: + """List functions.""" + run_tool(ctx, "function_operations", {"operation": "List"}) + + +@function.command() +@click.argument("name") +@click.option("--expression", "-e", required=True, help="Function expression.") +@pass_context +def function_create(ctx: PbiContext, name: str, expression: str) -> None: + """Create a function.""" + run_tool(ctx, "function_operations", { + "operation": "Create", + "definitions": [{"name": name, "expression": expression}], + }) + + +# --- Query Group --- + +@advanced.group(name="query-group") +def query_group() -> None: + """Manage query groups.""" + + +@query_group.command(name="list") +@pass_context +def qg_list(ctx: PbiContext) -> None: + """List query groups.""" + run_tool(ctx, "query_group_operations", {"operation": "List"}) + + +@query_group.command() +@click.argument("name") +@click.option("--folder", default=None, help="Folder path.") +@pass_context +def qg_create(ctx: PbiContext, name: str, folder: str | None) -> None: + """Create a query group.""" + definition = build_definition(required={"name": name}, optional={"folder": folder}) + run_tool(ctx, "query_group_operations", {"operation": "Create", "definitions": [definition]}) diff --git a/src/pbi_cli/commands/calc_group.py b/src/pbi_cli/commands/calc_group.py new file mode 100644 index 0000000..08db250 --- /dev/null +++ b/src/pbi_cli/commands/calc_group.py @@ -0,0 +1,69 @@ +"""Calculation group commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group(name="calc-group") +def calc_group() -> None: + """Manage calculation groups.""" + + +@calc_group.command(name="list") +@pass_context +def cg_list(ctx: PbiContext) -> None: + """List all calculation groups.""" + run_tool(ctx, "calculation_group_operations", {"operation": "ListGroups"}) + + +@calc_group.command() +@click.argument("name") +@click.option("--description", default=None, help="Group description.") +@click.option("--precedence", type=int, default=None, help="Calculation precedence.") +@pass_context +def create(ctx: PbiContext, name: str, description: str | None, precedence: int | None) -> None: + """Create a calculation group.""" + definition = build_definition( + required={"name": name}, + optional={"description": description, "calculationGroupPrecedence": precedence}, + ) + run_tool(ctx, "calculation_group_operations", {"operation": "CreateGroup", "definitions": [definition]}) + + +@calc_group.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a calculation group.""" + run_tool(ctx, "calculation_group_operations", {"operation": "DeleteGroup", "name": name}) + + +@calc_group.command(name="items") +@click.argument("group_name") +@pass_context +def list_items(ctx: PbiContext, group_name: str) -> None: + """List calculation items in a group.""" + run_tool(ctx, "calculation_group_operations", {"operation": "ListItems", "calculationGroupName": group_name}) + + +@calc_group.command(name="create-item") +@click.argument("item_name") +@click.option("--group", "-g", required=True, help="Calculation group name.") +@click.option("--expression", "-e", required=True, help="DAX expression.") +@click.option("--ordinal", type=int, default=None, help="Item ordinal.") +@pass_context +def create_item(ctx: PbiContext, item_name: str, group: str, expression: str, ordinal: int | None) -> None: + """Create a calculation item in a group.""" + definition = build_definition( + required={"name": item_name, "expression": expression}, + optional={"ordinal": ordinal}, + ) + run_tool(ctx, "calculation_group_operations", { + "operation": "CreateItem", + "calculationGroupName": group, + "definitions": [definition], + }) diff --git a/src/pbi_cli/commands/calendar.py b/src/pbi_cli/commands/calendar.py new file mode 100644 index 0000000..0b48d00 --- /dev/null +++ b/src/pbi_cli/commands/calendar.py @@ -0,0 +1,42 @@ +"""Calendar table commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def calendar() -> None: + """Manage calendar tables.""" + + +@calendar.command(name="list") +@pass_context +def calendar_list(ctx: PbiContext) -> None: + """List calendar tables.""" + run_tool(ctx, "calendar_operations", {"operation": "List"}) + + +@calendar.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Target table name.") +@click.option("--description", default=None, help="Calendar description.") +@pass_context +def create(ctx: PbiContext, name: str, table: str, description: str | None) -> None: + """Create a calendar table.""" + definition = build_definition( + required={"name": name, "tableName": table}, + optional={"description": description}, + ) + run_tool(ctx, "calendar_operations", {"operation": "Create", "definitions": [definition]}) + + +@calendar.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a calendar.""" + run_tool(ctx, "calendar_operations", {"operation": "Delete", "name": name}) diff --git a/src/pbi_cli/commands/column.py b/src/pbi_cli/commands/column.py new file mode 100644 index 0000000..acd5e50 --- /dev/null +++ b/src/pbi_cli/commands/column.py @@ -0,0 +1,104 @@ +"""Column CRUD commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def column() -> None: + """Manage columns in a semantic model.""" + + +@column.command(name="list") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def column_list(ctx: PbiContext, table: str) -> None: + """List all columns in a table.""" + run_tool(ctx, "column_operations", {"operation": "List", "tableName": table}) + + +@column.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def get(ctx: PbiContext, name: str, table: str) -> None: + """Get details of a specific column.""" + run_tool(ctx, "column_operations", {"operation": "Get", "name": name, "tableName": table}) + + +@column.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@click.option("--data-type", required=True, help="Data type (string, int64, double, datetime, etc.).") +@click.option("--source-column", default=None, help="Source column name (for Import mode).") +@click.option("--expression", default=None, help="DAX expression (for calculated columns).") +@click.option("--format-string", default=None, help="Format string.") +@click.option("--description", default=None, help="Column description.") +@click.option("--folder", default=None, help="Display folder.") +@click.option("--hidden", is_flag=True, default=False, help="Hide from client tools.") +@click.option("--is-key", is_flag=True, default=False, help="Mark as key column.") +@pass_context +def create( + ctx: PbiContext, + name: str, + table: str, + data_type: str, + source_column: str | None, + expression: str | None, + format_string: str | None, + description: str | None, + folder: str | None, + hidden: bool, + is_key: bool, +) -> None: + """Create a new column.""" + definition = build_definition( + required={"name": name, "tableName": table, "dataType": data_type}, + optional={ + "sourceColumn": source_column, + "expression": expression, + "formatString": format_string, + "description": description, + "displayFolder": folder, + "isHidden": hidden if hidden else None, + "isKey": is_key if is_key else None, + }, + ) + run_tool(ctx, "column_operations", {"operation": "Create", "definitions": [definition]}) + + +@column.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def delete(ctx: PbiContext, name: str, table: str) -> None: + """Delete a column.""" + run_tool(ctx, "column_operations", {"operation": "Delete", "name": name, "tableName": table}) + + +@column.command() +@click.argument("old_name") +@click.argument("new_name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def rename(ctx: PbiContext, old_name: str, new_name: str, table: str) -> None: + """Rename a column.""" + run_tool(ctx, "column_operations", { + "operation": "Rename", + "name": old_name, + "newName": new_name, + "tableName": table, + }) + + +@column.command(name="export-tmdl") +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def export_tmdl(ctx: PbiContext, name: str, table: str) -> None: + """Export a column as TMDL.""" + run_tool(ctx, "column_operations", {"operation": "ExportTMDL", "name": name, "tableName": table}) diff --git a/src/pbi_cli/commands/connection.py b/src/pbi_cli/commands/connection.py new file mode 100644 index 0000000..dc78075 --- /dev/null +++ b/src/pbi_cli/commands/connection.py @@ -0,0 +1,206 @@ +"""Connection management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.core.connection_store import ( + ConnectionInfo, + add_connection, + get_active_connection, + load_connections, + remove_connection, + save_connections, +) +from pbi_cli.core.mcp_client import PbiMcpClient, get_client +from pbi_cli.core.output import ( + format_mcp_result, + print_error, + print_json, + print_success, + print_table, +) +from pbi_cli.main import PbiContext, pass_context + + +@click.command() +@click.option("--data-source", "-d", required=True, help="Data source (e.g., localhost:54321).") +@click.option("--catalog", "-C", default="", help="Initial catalog / dataset name.") +@click.option("--name", "-n", default=None, help="Name for this connection (auto-generated if omitted).") +@click.option("--connection-string", default="", help="Full connection string (overrides data-source).") +@pass_context +def connect(ctx: PbiContext, data_source: str, catalog: str, name: str | None, connection_string: str) -> None: + """Connect to a Power BI instance via data source.""" + conn_name = name or _auto_name(data_source) + + request: dict = { + "operation": "Connect", + "connectionName": conn_name, + "dataSource": data_source, + } + if catalog: + request["initialCatalog"] = catalog + if connection_string: + request["connectionString"] = connection_string + + client = get_client() + try: + result = client.call_tool("connection_operations", request) + + info = ConnectionInfo( + name=conn_name, + data_source=data_source, + initial_catalog=catalog, + connection_string=connection_string, + ) + store = load_connections() + store = add_connection(store, info) + save_connections(store) + + if ctx.json_output: + print_json({"connection": conn_name, "status": "connected", "result": result}) + else: + print_success(f"Connected: {conn_name} ({data_source})") + except Exception as e: + print_error(f"Connection failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +@click.command(name="connect-fabric") +@click.option("--workspace", "-w", required=True, help="Fabric workspace name (exact match).") +@click.option("--model", "-m", required=True, help="Semantic model name (exact match).") +@click.option("--name", "-n", default=None, help="Name for this connection.") +@click.option("--tenant", default="myorg", help="Tenant name for B2B scenarios.") +@pass_context +def connect_fabric(ctx: PbiContext, workspace: str, model: str, name: str | None, tenant: str) -> None: + """Connect to a Fabric workspace semantic model.""" + conn_name = name or f"{workspace}/{model}" + + request: dict = { + "operation": "ConnectFabric", + "connectionName": conn_name, + "workspaceName": workspace, + "semanticModelName": model, + "tenantName": tenant, + } + + client = get_client() + try: + result = client.call_tool("connection_operations", request) + + info = ConnectionInfo( + name=conn_name, + data_source=f"powerbi://api.powerbi.com/v1.0/{tenant}/{workspace}", + workspace_name=workspace, + semantic_model_name=model, + tenant_name=tenant, + ) + store = load_connections() + store = add_connection(store, info) + save_connections(store) + + if ctx.json_output: + print_json({"connection": conn_name, "status": "connected", "result": result}) + else: + print_success(f"Connected to Fabric: {workspace}/{model}") + except Exception as e: + print_error(f"Fabric connection failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +@click.command() +@click.option("--name", "-n", default=None, help="Connection name to disconnect (defaults to active).") +@pass_context +def disconnect(ctx: PbiContext, name: str | None) -> None: + """Disconnect from the active or named connection.""" + store = load_connections() + target = name or store.last_used + + if not target: + print_error("No active connection to disconnect.") + raise SystemExit(1) + + client = get_client() + try: + result = client.call_tool("connection_operations", { + "operation": "Disconnect", + "connectionName": target, + }) + + store = remove_connection(store, target) + save_connections(store) + + if ctx.json_output: + print_json({"connection": target, "status": "disconnected"}) + else: + print_success(f"Disconnected: {target}") + except Exception as e: + print_error(f"Disconnect failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +@click.group() +def connections() -> None: + """Manage saved connections.""" + + +@connections.command(name="list") +@pass_context +def connections_list(ctx: PbiContext) -> None: + """List all saved connections.""" + store = load_connections() + + if ctx.json_output: + from dataclasses import asdict + data = { + "last_used": store.last_used, + "connections": [asdict(c) for c in store.connections.values()], + } + print_json(data) + return + + if not store.connections: + print_error("No saved connections. Run 'pbi connect' first.") + return + + rows = [] + for info in store.connections.values(): + active = "*" if info.name == store.last_used else "" + rows.append([active, info.name, info.data_source, info.initial_catalog]) + + print_table("Connections", ["Active", "Name", "Data Source", "Catalog"], rows) + + +@connections.command(name="last") +@pass_context +def connections_last(ctx: PbiContext) -> None: + """Show the last-used connection.""" + store = load_connections() + conn = get_active_connection(store) + + if conn is None: + print_error("No active connection.") + raise SystemExit(1) + + if ctx.json_output: + from dataclasses import asdict + print_json(asdict(conn)) + else: + from pbi_cli.core.output import print_key_value + print_key_value("Active Connection", { + "Name": conn.name, + "Data Source": conn.data_source, + "Catalog": conn.initial_catalog, + }) + + +def _auto_name(data_source: str) -> str: + """Generate a connection name from a data source string.""" + cleaned = data_source.replace("://", "-").replace("/", "-").replace(":", "-") + return cleaned[:50] diff --git a/src/pbi_cli/commands/database.py b/src/pbi_cli/commands/database.py new file mode 100644 index 0000000..9b2c829 --- /dev/null +++ b/src/pbi_cli/commands/database.py @@ -0,0 +1,68 @@ +"""Database-level operations: list, TMDL import/export, Fabric deploy.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def database() -> None: + """Manage semantic models (databases) at the top level.""" + + +@database.command(name="list") +@pass_context +def database_list(ctx: PbiContext) -> None: + """List all databases on the connected server.""" + run_tool(ctx, "database_operations", {"operation": "List"}) + + +@database.command(name="import-tmdl") +@click.argument("folder_path", type=click.Path(exists=True)) +@pass_context +def import_tmdl(ctx: PbiContext, folder_path: str) -> None: + """Import a model from a TMDL folder.""" + run_tool(ctx, "database_operations", { + "operation": "ImportFromTmdlFolder", + "tmdlFolderPath": folder_path, + }) + + +@database.command(name="export-tmdl") +@click.argument("folder_path", type=click.Path()) +@pass_context +def export_tmdl(ctx: PbiContext, folder_path: str) -> None: + """Export the model to a TMDL folder.""" + run_tool(ctx, "database_operations", { + "operation": "ExportToTmdlFolder", + "tmdlFolderPath": folder_path, + }) + + +@database.command(name="export-tmsl") +@pass_context +def export_tmsl(ctx: PbiContext) -> None: + """Export the model as TMSL.""" + run_tool(ctx, "database_operations", {"operation": "ExportTMSL"}) + + +@database.command() +@click.option("--workspace", "-w", required=True, help="Target Fabric workspace name.") +@click.option("--new-name", default=None, help="New database name in target workspace.") +@click.option("--tenant", default=None, help="Tenant name for B2B scenarios.") +@pass_context +def deploy(ctx: PbiContext, workspace: str, new_name: str | None, tenant: str | None) -> None: + """Deploy the model to a Fabric workspace.""" + deploy_request: dict = {"targetWorkspaceName": workspace} + if new_name: + deploy_request["newDatabaseName"] = new_name + if tenant: + deploy_request["targetTenantName"] = tenant + + run_tool(ctx, "database_operations", { + "operation": "DeployToFabric", + "deployToFabricRequest": deploy_request, + }) diff --git a/src/pbi_cli/commands/dax.py b/src/pbi_cli/commands/dax.py new file mode 100644 index 0000000..5c82c5a --- /dev/null +++ b/src/pbi_cli/commands/dax.py @@ -0,0 +1,131 @@ +"""DAX query commands: execute, validate, clear-cache.""" + +from __future__ import annotations + +import sys + +import click + +from pbi_cli.core.mcp_client import get_client +from pbi_cli.core.output import format_mcp_result, print_error, print_json +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def dax() -> None: + """Execute and validate DAX queries.""" + + +@dax.command() +@click.argument("query", default="") +@click.option("--file", "-f", "query_file", type=click.Path(exists=True), help="Read query from file.") +@click.option("--max-rows", type=int, default=None, help="Maximum rows to return.") +@click.option("--metrics", is_flag=True, default=False, help="Include execution metrics.") +@click.option("--metrics-only", is_flag=True, default=False, help="Return metrics without row data.") +@click.option("--timeout", type=int, default=200, help="Query timeout in seconds.") +@pass_context +def execute( + ctx: PbiContext, + query: str, + query_file: str | None, + max_rows: int | None, + metrics: bool, + metrics_only: bool, + timeout: int, +) -> None: + """Execute a DAX query. + + Pass the query as an argument, via --file, or pipe it from stdin: + + pbi dax execute "EVALUATE Sales" + + pbi dax execute --file query.dax + + cat query.dax | pbi dax execute - + """ + resolved_query = _resolve_query(query, query_file) + if not resolved_query: + print_error("No query provided. Pass as argument, --file, or stdin.") + raise SystemExit(1) + + request: dict = { + "operation": "Execute", + "query": resolved_query, + "timeoutSeconds": timeout, + "getExecutionMetrics": metrics or metrics_only, + "executionMetricsOnly": metrics_only, + } + if ctx.connection: + request["connectionName"] = ctx.connection + if max_rows is not None: + request["maxRows"] = max_rows + + client = get_client() + try: + result = client.call_tool("dax_query_operations", request) + format_mcp_result(result, ctx.json_output) + except Exception as e: + print_error(f"DAX execution failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +@dax.command() +@click.argument("query", default="") +@click.option("--file", "-f", "query_file", type=click.Path(exists=True), help="Read query from file.") +@click.option("--timeout", type=int, default=10, help="Validation timeout in seconds.") +@pass_context +def validate(ctx: PbiContext, query: str, query_file: str | None, timeout: int) -> None: + """Validate a DAX query without executing it.""" + resolved_query = _resolve_query(query, query_file) + if not resolved_query: + print_error("No query provided.") + raise SystemExit(1) + + request: dict = { + "operation": "Validate", + "query": resolved_query, + "timeoutSeconds": timeout, + } + if ctx.connection: + request["connectionName"] = ctx.connection + + client = get_client() + try: + result = client.call_tool("dax_query_operations", request) + format_mcp_result(result, ctx.json_output) + except Exception as e: + print_error(f"DAX validation failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +@dax.command(name="clear-cache") +@pass_context +def clear_cache(ctx: PbiContext) -> None: + """Clear the DAX query cache.""" + request: dict = {"operation": "ClearCache"} + if ctx.connection: + request["connectionName"] = ctx.connection + + client = get_client() + try: + result = client.call_tool("dax_query_operations", request) + format_mcp_result(result, ctx.json_output) + except Exception as e: + print_error(f"Cache clear failed: {e}") + raise SystemExit(1) + finally: + client.stop() + + +def _resolve_query(query: str, query_file: str | None) -> str: + """Resolve the DAX query from argument, file, or stdin.""" + if query == "-": + return sys.stdin.read().strip() + if query_file: + with open(query_file, encoding="utf-8") as f: + return f.read().strip() + return query.strip() diff --git a/src/pbi_cli/commands/expression.py b/src/pbi_cli/commands/expression.py new file mode 100644 index 0000000..3d0f02a --- /dev/null +++ b/src/pbi_cli/commands/expression.py @@ -0,0 +1,64 @@ +"""Named expression and parameter commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def expression() -> None: + """Manage named expressions and parameters.""" + + +@expression.command(name="list") +@pass_context +def expression_list(ctx: PbiContext) -> None: + """List all named expressions.""" + run_tool(ctx, "named_expression_operations", {"operation": "List"}) + + +@expression.command() +@click.argument("name") +@pass_context +def get(ctx: PbiContext, name: str) -> None: + """Get a named expression.""" + run_tool(ctx, "named_expression_operations", {"operation": "Get", "name": name}) + + +@expression.command() +@click.argument("name") +@click.option("--expression", "-e", required=True, help="M expression.") +@click.option("--description", default=None, help="Expression description.") +@pass_context +def create(ctx: PbiContext, name: str, expression: str, description: str | None) -> None: + """Create a named expression.""" + definition = build_definition( + required={"name": name, "expression": expression}, + optional={"description": description}, + ) + run_tool(ctx, "named_expression_operations", {"operation": "Create", "definitions": [definition]}) + + +@expression.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a named expression.""" + run_tool(ctx, "named_expression_operations", {"operation": "Delete", "name": name}) + + +@expression.command(name="create-param") +@click.argument("name") +@click.option("--expression", "-e", required=True, help="Default value expression.") +@click.option("--description", default=None, help="Parameter description.") +@pass_context +def create_param(ctx: PbiContext, name: str, expression: str, description: str | None) -> None: + """Create a model parameter.""" + definition = build_definition( + required={"name": name, "expression": expression}, + optional={"description": description}, + ) + run_tool(ctx, "named_expression_operations", {"operation": "CreateParameter", "definitions": [definition]}) diff --git a/src/pbi_cli/commands/hierarchy.py b/src/pbi_cli/commands/hierarchy.py new file mode 100644 index 0000000..9574f39 --- /dev/null +++ b/src/pbi_cli/commands/hierarchy.py @@ -0,0 +1,56 @@ +"""User hierarchy commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def hierarchy() -> None: + """Manage user hierarchies.""" + + +@hierarchy.command(name="list") +@click.option("--table", "-t", default=None, help="Filter by table.") +@pass_context +def hierarchy_list(ctx: PbiContext, table: str | None) -> None: + """List hierarchies.""" + request: dict = {"operation": "List"} + if table: + request["tableName"] = table + run_tool(ctx, "user_hierarchy_operations", request) + + +@hierarchy.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def get(ctx: PbiContext, name: str, table: str) -> None: + """Get hierarchy details.""" + run_tool(ctx, "user_hierarchy_operations", {"operation": "Get", "name": name, "tableName": table}) + + +@hierarchy.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@click.option("--description", default=None, help="Hierarchy description.") +@pass_context +def create(ctx: PbiContext, name: str, table: str, description: str | None) -> None: + """Create a hierarchy.""" + definition = build_definition( + required={"name": name, "tableName": table}, + optional={"description": description}, + ) + run_tool(ctx, "user_hierarchy_operations", {"operation": "Create", "definitions": [definition]}) + + +@hierarchy.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def delete(ctx: PbiContext, name: str, table: str) -> None: + """Delete a hierarchy.""" + run_tool(ctx, "user_hierarchy_operations", {"operation": "Delete", "name": name, "tableName": table}) diff --git a/src/pbi_cli/commands/measure.py b/src/pbi_cli/commands/measure.py new file mode 100644 index 0000000..4fe3598 --- /dev/null +++ b/src/pbi_cli/commands/measure.py @@ -0,0 +1,169 @@ +"""Measure CRUD commands.""" + +from __future__ import annotations + +import sys + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def measure() -> None: + """Manage measures in a semantic model.""" + + +@measure.command(name="list") +@click.option("--table", "-t", default=None, help="Filter by table name.") +@pass_context +def measure_list(ctx: PbiContext, table: str | None) -> None: + """List all measures.""" + request: dict = {"operation": "List"} + if table: + request["tableName"] = table + run_tool(ctx, "measure_operations", request) + + +@measure.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table containing the measure.") +@pass_context +def get(ctx: PbiContext, name: str, table: str) -> None: + """Get details of a specific measure.""" + run_tool(ctx, "measure_operations", { + "operation": "Get", + "name": name, + "tableName": table, + }) + + +@measure.command() +@click.argument("name") +@click.option("--expression", "-e", required=True, help="DAX expression (use - for stdin).") +@click.option("--table", "-t", required=True, help="Target table.") +@click.option("--format-string", default=None, help='Format string (e.g., "$#,##0").') +@click.option("--description", default=None, help="Measure description.") +@click.option("--folder", default=None, help="Display folder path.") +@click.option("--hidden", is_flag=True, default=False, help="Hide from client tools.") +@pass_context +def create( + ctx: PbiContext, + name: str, + expression: str, + table: str, + format_string: str | None, + description: str | None, + folder: str | None, + hidden: bool, +) -> None: + """Create a new measure.""" + if expression == "-": + expression = sys.stdin.read().strip() + + definition = build_definition( + required={"name": name, "expression": expression, "tableName": table}, + optional={ + "formatString": format_string, + "description": description, + "displayFolder": folder, + "isHidden": hidden if hidden else None, + }, + ) + run_tool(ctx, "measure_operations", { + "operation": "Create", + "definitions": [definition], + }) + + +@measure.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table containing the measure.") +@click.option("--expression", "-e", default=None, help="New DAX expression.") +@click.option("--format-string", default=None, help="New format string.") +@click.option("--description", default=None, help="New description.") +@click.option("--folder", default=None, help="New display folder.") +@pass_context +def update( + ctx: PbiContext, + name: str, + table: str, + expression: str | None, + format_string: str | None, + description: str | None, + folder: str | None, +) -> None: + """Update an existing measure.""" + if expression == "-": + expression = sys.stdin.read().strip() + + definition = build_definition( + required={"name": name, "tableName": table}, + optional={ + "expression": expression, + "formatString": format_string, + "description": description, + "displayFolder": folder, + }, + ) + run_tool(ctx, "measure_operations", { + "operation": "Update", + "definitions": [definition], + }) + + +@measure.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table containing the measure.") +@pass_context +def delete(ctx: PbiContext, name: str, table: str) -> None: + """Delete a measure.""" + run_tool(ctx, "measure_operations", { + "operation": "Delete", + "name": name, + "tableName": table, + }) + + +@measure.command() +@click.argument("old_name") +@click.argument("new_name") +@click.option("--table", "-t", required=True, help="Table containing the measure.") +@pass_context +def rename(ctx: PbiContext, old_name: str, new_name: str, table: str) -> None: + """Rename a measure.""" + run_tool(ctx, "measure_operations", { + "operation": "Rename", + "name": old_name, + "newName": new_name, + "tableName": table, + }) + + +@measure.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Source table.") +@click.option("--to-table", required=True, help="Destination table.") +@pass_context +def move(ctx: PbiContext, name: str, table: str, to_table: str) -> None: + """Move a measure to a different table.""" + run_tool(ctx, "measure_operations", { + "operation": "Move", + "name": name, + "tableName": table, + "destinationTableName": to_table, + }) + + +@measure.command(name="export-tmdl") +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table containing the measure.") +@pass_context +def export_tmdl(ctx: PbiContext, name: str, table: str) -> None: + """Export a measure as TMDL.""" + run_tool(ctx, "measure_operations", { + "operation": "ExportTMDL", + "name": name, + "tableName": table, + }) diff --git a/src/pbi_cli/commands/model.py b/src/pbi_cli/commands/model.py new file mode 100644 index 0000000..fa56ddd --- /dev/null +++ b/src/pbi_cli/commands/model.py @@ -0,0 +1,50 @@ +"""Model-level operations.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def model() -> None: + """Manage the semantic model.""" + + +@model.command() +@pass_context +def get(ctx: PbiContext) -> None: + """Get model metadata.""" + run_tool(ctx, "model_operations", {"operation": "Get"}) + + +@model.command() +@pass_context +def stats(ctx: PbiContext) -> None: + """Get model statistics.""" + run_tool(ctx, "model_operations", {"operation": "GetStats"}) + + +@model.command() +@click.option("--type", "refresh_type", type=click.Choice(["Automatic", "Full", "Calculate", "DataOnly", "Defragment"]), default="Automatic", help="Refresh type.") +@pass_context +def refresh(ctx: PbiContext, refresh_type: str) -> None: + """Refresh the model.""" + run_tool(ctx, "model_operations", {"operation": "Refresh", "refreshType": refresh_type}) + + +@model.command() +@click.argument("new_name") +@pass_context +def rename(ctx: PbiContext, new_name: str) -> None: + """Rename the model.""" + run_tool(ctx, "model_operations", {"operation": "Rename", "newName": new_name}) + + +@model.command(name="export-tmdl") +@pass_context +def export_tmdl(ctx: PbiContext) -> None: + """Export the model as TMDL.""" + run_tool(ctx, "model_operations", {"operation": "ExportTMDL"}) diff --git a/src/pbi_cli/commands/partition.py b/src/pbi_cli/commands/partition.py new file mode 100644 index 0000000..e4b4919 --- /dev/null +++ b/src/pbi_cli/commands/partition.py @@ -0,0 +1,54 @@ +"""Partition management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def partition() -> None: + """Manage table partitions.""" + + +@partition.command(name="list") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def partition_list(ctx: PbiContext, table: str) -> None: + """List partitions in a table.""" + run_tool(ctx, "partition_operations", {"operation": "List", "tableName": table}) + + +@partition.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@click.option("--expression", "-e", default=None, help="M/Power Query expression.") +@click.option("--mode", type=click.Choice(["Import", "DirectQuery", "Dual"]), default=None) +@pass_context +def create(ctx: PbiContext, name: str, table: str, expression: str | None, mode: str | None) -> None: + """Create a partition.""" + definition = build_definition( + required={"name": name, "tableName": table}, + optional={"expression": expression, "mode": mode}, + ) + run_tool(ctx, "partition_operations", {"operation": "Create", "definitions": [definition]}) + + +@partition.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def delete(ctx: PbiContext, name: str, table: str) -> None: + """Delete a partition.""" + run_tool(ctx, "partition_operations", {"operation": "Delete", "name": name, "tableName": table}) + + +@partition.command() +@click.argument("name") +@click.option("--table", "-t", required=True, help="Table name.") +@pass_context +def refresh(ctx: PbiContext, name: str, table: str) -> None: + """Refresh a partition.""" + run_tool(ctx, "partition_operations", {"operation": "Refresh", "name": name, "tableName": table}) diff --git a/src/pbi_cli/commands/perspective.py b/src/pbi_cli/commands/perspective.py new file mode 100644 index 0000000..505647f --- /dev/null +++ b/src/pbi_cli/commands/perspective.py @@ -0,0 +1,38 @@ +"""Perspective management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def perspective() -> None: + """Manage model perspectives.""" + + +@perspective.command(name="list") +@pass_context +def perspective_list(ctx: PbiContext) -> None: + """List all perspectives.""" + run_tool(ctx, "perspective_operations", {"operation": "List"}) + + +@perspective.command() +@click.argument("name") +@click.option("--description", default=None, help="Perspective description.") +@pass_context +def create(ctx: PbiContext, name: str, description: str | None) -> None: + """Create a perspective.""" + definition = build_definition(required={"name": name}, optional={"description": description}) + run_tool(ctx, "perspective_operations", {"operation": "Create", "definitions": [definition]}) + + +@perspective.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a perspective.""" + run_tool(ctx, "perspective_operations", {"operation": "Delete", "name": name}) diff --git a/src/pbi_cli/commands/relationship.py b/src/pbi_cli/commands/relationship.py new file mode 100644 index 0000000..5741ce4 --- /dev/null +++ b/src/pbi_cli/commands/relationship.py @@ -0,0 +1,104 @@ +"""Relationship management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def relationship() -> None: + """Manage relationships between tables.""" + + +@relationship.command(name="list") +@pass_context +def relationship_list(ctx: PbiContext) -> None: + """List all relationships.""" + run_tool(ctx, "relationship_operations", {"operation": "List"}) + + +@relationship.command() +@click.argument("name") +@pass_context +def get(ctx: PbiContext, name: str) -> None: + """Get details of a specific relationship.""" + run_tool(ctx, "relationship_operations", {"operation": "Get", "name": name}) + + +@relationship.command() +@click.option("--name", "-n", default=None, help="Relationship name (auto-generated if omitted).") +@click.option("--from-table", required=True, help="Source (many-side) table.") +@click.option("--from-column", required=True, help="Source column.") +@click.option("--to-table", required=True, help="Target (one-side) table.") +@click.option("--to-column", required=True, help="Target column.") +@click.option("--cross-filter", type=click.Choice(["OneDirection", "BothDirections", "Automatic"]), default="OneDirection", help="Cross-filtering behavior.") +@click.option("--active/--inactive", default=True, help="Whether the relationship is active.") +@pass_context +def create( + ctx: PbiContext, + name: str | None, + from_table: str, + from_column: str, + to_table: str, + to_column: str, + cross_filter: str, + active: bool, +) -> None: + """Create a new relationship.""" + definition = build_definition( + required={ + "fromTable": from_table, + "fromColumn": from_column, + "toTable": to_table, + "toColumn": to_column, + }, + optional={ + "name": name, + "crossFilteringBehavior": cross_filter, + "isActive": active, + }, + ) + run_tool(ctx, "relationship_operations", {"operation": "Create", "definitions": [definition]}) + + +@relationship.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a relationship.""" + run_tool(ctx, "relationship_operations", {"operation": "Delete", "name": name}) + + +@relationship.command() +@click.argument("name") +@pass_context +def activate(ctx: PbiContext, name: str) -> None: + """Activate a relationship.""" + run_tool(ctx, "relationship_operations", {"operation": "Activate", "name": name}) + + +@relationship.command() +@click.argument("name") +@pass_context +def deactivate(ctx: PbiContext, name: str) -> None: + """Deactivate a relationship.""" + run_tool(ctx, "relationship_operations", {"operation": "Deactivate", "name": name}) + + +@relationship.command() +@click.option("--table", "-t", required=True, help="Table to search for relationships.") +@pass_context +def find(ctx: PbiContext, table: str) -> None: + """Find relationships involving a table.""" + run_tool(ctx, "relationship_operations", {"operation": "Find", "tableName": table}) + + +@relationship.command(name="export-tmdl") +@click.argument("name") +@pass_context +def export_tmdl(ctx: PbiContext, name: str) -> None: + """Export a relationship as TMDL.""" + run_tool(ctx, "relationship_operations", {"operation": "ExportTMDL", "name": name}) diff --git a/src/pbi_cli/commands/security.py b/src/pbi_cli/commands/security.py new file mode 100644 index 0000000..94d0e93 --- /dev/null +++ b/src/pbi_cli/commands/security.py @@ -0,0 +1,57 @@ +"""Security role management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group(name="security-role") +def security_role() -> None: + """Manage security roles (RLS).""" + + +@security_role.command(name="list") +@pass_context +def role_list(ctx: PbiContext) -> None: + """List all security roles.""" + run_tool(ctx, "security_role_operations", {"operation": "List"}) + + +@security_role.command() +@click.argument("name") +@pass_context +def get(ctx: PbiContext, name: str) -> None: + """Get details of a security role.""" + run_tool(ctx, "security_role_operations", {"operation": "Get", "name": name}) + + +@security_role.command() +@click.argument("name") +@click.option("--description", default=None, help="Role description.") +@pass_context +def create(ctx: PbiContext, name: str, description: str | None) -> None: + """Create a new security role.""" + definition = build_definition( + required={"name": name}, + optional={"description": description}, + ) + run_tool(ctx, "security_role_operations", {"operation": "Create", "definitions": [definition]}) + + +@security_role.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a security role.""" + run_tool(ctx, "security_role_operations", {"operation": "Delete", "name": name}) + + +@security_role.command(name="export-tmdl") +@click.argument("name") +@pass_context +def export_tmdl(ctx: PbiContext, name: str) -> None: + """Export a security role as TMDL.""" + run_tool(ctx, "security_role_operations", {"operation": "ExportTMDL", "name": name}) diff --git a/src/pbi_cli/commands/setup_cmd.py b/src/pbi_cli/commands/setup_cmd.py new file mode 100644 index 0000000..7997d0b --- /dev/null +++ b/src/pbi_cli/commands/setup_cmd.py @@ -0,0 +1,76 @@ +"""pbi setup: download and manage the Power BI MCP binary.""" + +from __future__ import annotations + +import click + +from pbi_cli.core.binary_manager import ( + check_for_updates, + download_and_extract, + get_binary_info, + resolve_binary, +) +from pbi_cli.core.output import print_error, print_info, print_json, print_key_value, print_success +from pbi_cli.main import PbiContext, pass_context + + +@click.command() +@click.option("--version", "target_version", default=None, help="Specific version to install.") +@click.option("--check", is_flag=True, default=False, help="Check for updates without installing.") +@click.option("--info", is_flag=True, default=False, help="Show info about the current binary.") +@pass_context +def setup(ctx: PbiContext, target_version: str | None, check: bool, info: bool) -> None: + """Download and set up the Power BI MCP server binary. + + Run this once after installing pbi-cli to download the binary. + """ + if info: + _show_info(ctx.json_output) + return + + if check: + _check_updates(ctx.json_output) + return + + _install(target_version, ctx.json_output) + + +def _show_info(json_output: bool) -> None: + """Show binary info.""" + info = get_binary_info() + if json_output: + print_json(info) + else: + print_key_value("Power BI MCP Binary", info) + + +def _check_updates(json_output: bool) -> None: + """Check for available updates.""" + try: + installed, latest, update_available = check_for_updates() + result = { + "installed_version": installed, + "latest_version": latest, + "update_available": update_available, + } + if json_output: + print_json(result) + elif update_available: + print_info(f"Update available: {installed} -> {latest}") + print_info("Run 'pbi setup' to update.") + else: + print_success(f"Up to date: v{installed}") + except Exception as e: + print_error(f"Failed to check for updates: {e}") + raise SystemExit(1) + + +def _install(version: str | None, json_output: bool) -> None: + """Download and install the binary.""" + try: + bin_path = download_and_extract(version) + if json_output: + print_json({"binary_path": str(bin_path), "status": "installed"}) + except Exception as e: + print_error(f"Setup failed: {e}") + raise SystemExit(1) diff --git a/src/pbi_cli/commands/table.py b/src/pbi_cli/commands/table.py new file mode 100644 index 0000000..624e6c4 --- /dev/null +++ b/src/pbi_cli/commands/table.py @@ -0,0 +1,135 @@ +"""Table CRUD commands.""" + +from __future__ import annotations + +import sys + +import click + +from pbi_cli.commands._helpers import build_definition, run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def table() -> None: + """Manage tables in a semantic model.""" + + +@table.command(name="list") +@pass_context +def table_list(ctx: PbiContext) -> None: + """List all tables.""" + run_tool(ctx, "table_operations", {"operation": "List"}) + + +@table.command() +@click.argument("name") +@pass_context +def get(ctx: PbiContext, name: str) -> None: + """Get details of a specific table.""" + run_tool(ctx, "table_operations", {"operation": "Get", "name": name}) + + +@table.command() +@click.argument("name") +@click.option("--mode", type=click.Choice(["Import", "DirectQuery", "Dual"]), default="Import", help="Table mode.") +@click.option("--m-expression", default=None, help="M/Power Query expression (use - for stdin).") +@click.option("--dax-expression", default=None, help="DAX expression for calculated tables.") +@click.option("--sql-query", default=None, help="SQL query for DirectQuery.") +@click.option("--description", default=None, help="Table description.") +@click.option("--hidden", is_flag=True, default=False, help="Hide from client tools.") +@pass_context +def create( + ctx: PbiContext, + name: str, + mode: str, + m_expression: str | None, + dax_expression: str | None, + sql_query: str | None, + description: str | None, + hidden: bool, +) -> None: + """Create a new table.""" + if m_expression == "-": + m_expression = sys.stdin.read().strip() + if dax_expression == "-": + dax_expression = sys.stdin.read().strip() + + definition = build_definition( + required={"name": name}, + optional={ + "mode": mode, + "mExpression": m_expression, + "daxExpression": dax_expression, + "sqlQuery": sql_query, + "description": description, + "isHidden": hidden if hidden else None, + }, + ) + run_tool(ctx, "table_operations", { + "operation": "Create", + "definitions": [definition], + }) + + +@table.command() +@click.argument("name") +@pass_context +def delete(ctx: PbiContext, name: str) -> None: + """Delete a table.""" + run_tool(ctx, "table_operations", {"operation": "Delete", "name": name}) + + +@table.command() +@click.argument("name") +@click.option("--type", "refresh_type", type=click.Choice(["Full", "Automatic", "Calculate", "DataOnly"]), default="Automatic", help="Refresh type.") +@pass_context +def refresh(ctx: PbiContext, name: str, refresh_type: str) -> None: + """Refresh a table.""" + run_tool(ctx, "table_operations", { + "operation": "Refresh", + "name": name, + "refreshType": refresh_type, + }) + + +@table.command() +@click.argument("name") +@pass_context +def schema(ctx: PbiContext, name: str) -> None: + """Get the schema of a table.""" + run_tool(ctx, "table_operations", {"operation": "GetSchema", "name": name}) + + +@table.command(name="export-tmdl") +@click.argument("name") +@pass_context +def export_tmdl(ctx: PbiContext, name: str) -> None: + """Export a table as TMDL.""" + run_tool(ctx, "table_operations", {"operation": "ExportTMDL", "name": name}) + + +@table.command() +@click.argument("old_name") +@click.argument("new_name") +@pass_context +def rename(ctx: PbiContext, old_name: str, new_name: str) -> None: + """Rename a table.""" + run_tool(ctx, "table_operations", { + "operation": "Rename", + "name": old_name, + "newName": new_name, + }) + + +@table.command(name="mark-date") +@click.argument("name") +@click.option("--date-column", required=True, help="Date column to use.") +@pass_context +def mark_date_table(ctx: PbiContext, name: str, date_column: str) -> None: + """Mark a table as a date table.""" + run_tool(ctx, "table_operations", { + "operation": "MarkAsDateTable", + "name": name, + "dateColumn": date_column, + }) diff --git a/src/pbi_cli/commands/trace.py b/src/pbi_cli/commands/trace.py new file mode 100644 index 0000000..3b9b5dc --- /dev/null +++ b/src/pbi_cli/commands/trace.py @@ -0,0 +1,42 @@ +"""Diagnostic trace commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def trace() -> None: + """Manage diagnostic traces.""" + + +@trace.command() +@pass_context +def start(ctx: PbiContext) -> None: + """Start a diagnostic trace.""" + run_tool(ctx, "trace_operations", {"operation": "Start"}) + + +@trace.command() +@pass_context +def stop(ctx: PbiContext) -> None: + """Stop the active trace.""" + run_tool(ctx, "trace_operations", {"operation": "Stop"}) + + +@trace.command() +@pass_context +def fetch(ctx: PbiContext) -> None: + """Fetch trace events.""" + run_tool(ctx, "trace_operations", {"operation": "Fetch"}) + + +@trace.command() +@click.argument("path", type=click.Path()) +@pass_context +def export(ctx: PbiContext, path: str) -> None: + """Export trace events to a file.""" + run_tool(ctx, "trace_operations", {"operation": "Export", "filePath": path}) diff --git a/src/pbi_cli/commands/transaction.py b/src/pbi_cli/commands/transaction.py new file mode 100644 index 0000000..f18d9d4 --- /dev/null +++ b/src/pbi_cli/commands/transaction.py @@ -0,0 +1,42 @@ +"""Transaction management commands.""" + +from __future__ import annotations + +import click + +from pbi_cli.commands._helpers import run_tool +from pbi_cli.main import PbiContext, pass_context + + +@click.group() +def transaction() -> None: + """Manage explicit transactions.""" + + +@transaction.command() +@pass_context +def begin(ctx: PbiContext) -> None: + """Begin a new transaction.""" + run_tool(ctx, "transaction_operations", {"operation": "Begin"}) + + +@transaction.command() +@click.argument("transaction_id", default="") +@pass_context +def commit(ctx: PbiContext, transaction_id: str) -> None: + """Commit the active or specified transaction.""" + request: dict = {"operation": "Commit"} + if transaction_id: + request["transactionId"] = transaction_id + run_tool(ctx, "transaction_operations", request) + + +@transaction.command() +@click.argument("transaction_id", default="") +@pass_context +def rollback(ctx: PbiContext, transaction_id: str) -> None: + """Rollback the active or specified transaction.""" + request: dict = {"operation": "Rollback"} + if transaction_id: + request["transactionId"] = transaction_id + run_tool(ctx, "transaction_operations", request) diff --git a/src/pbi_cli/core/__init__.py b/src/pbi_cli/core/__init__.py new file mode 100644 index 0000000..73479a1 --- /dev/null +++ b/src/pbi_cli/core/__init__.py @@ -0,0 +1 @@ +"""Core modules for pbi-cli.""" diff --git a/src/pbi_cli/core/binary_manager.py b/src/pbi_cli/core/binary_manager.py new file mode 100644 index 0000000..29a5f8f --- /dev/null +++ b/src/pbi_cli/core/binary_manager.py @@ -0,0 +1,249 @@ +"""Binary manager: download, extract, and resolve the Power BI MCP server binary. + +The binary is a .NET executable distributed as part of a VS Code extension (VSIX). +This module handles downloading the VSIX from the VS Marketplace, extracting the +server binary, and resolving the binary path for the MCP client. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import Any + +import httpx + +from pbi_cli.core.config import PBI_CLI_HOME, PbiConfig, ensure_home_dir, load_config, save_config +from pbi_cli.core.output import print_error, print_info, print_success, print_warning +from pbi_cli.utils.platform import ( + binary_name, + detect_platform, + ensure_executable, + find_vscode_extension_binary, +) + + +EXTENSION_ID = "analysis-services.powerbi-modeling-mcp" +PUBLISHER = "analysis-services" +EXTENSION_NAME = "powerbi-modeling-mcp" + +MARKETPLACE_API = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" +VSIX_URL_TEMPLATE = ( + "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/" + "{publisher}/vsextensions/{extension}/{version}/vspackage" + "?targetPlatform={platform}" +) + + +def resolve_binary() -> Path: + """Resolve the MCP server binary path using the priority chain. + + Priority: + 1. PBI_MCP_BINARY environment variable + 2. ~/.pbi-cli/bin/{version}/ (managed by pbi setup) + 3. VS Code extension fallback + + Raises FileNotFoundError if no binary is found. + """ + env_path = os.environ.get("PBI_MCP_BINARY") + if env_path: + p = Path(env_path) + if p.exists(): + return p + raise FileNotFoundError(f"PBI_MCP_BINARY points to non-existent path: {env_path}") + + config = load_config() + if config.binary_path: + p = Path(config.binary_path) + if p.exists(): + return p + + managed = _find_managed_binary() + if managed: + return managed + + vscode_bin = find_vscode_extension_binary() + if vscode_bin: + print_info(f"Using VS Code extension binary: {vscode_bin}") + return vscode_bin + + raise FileNotFoundError( + "Power BI MCP binary not found. Run 'pbi setup' to download it, " + "or set PBI_MCP_BINARY environment variable." + ) + + +def _find_managed_binary() -> Path | None: + """Look for a binary in ~/.pbi-cli/bin/.""" + bin_dir = PBI_CLI_HOME / "bin" + if not bin_dir.exists(): + return None + versions = sorted(bin_dir.iterdir(), reverse=True) + for version_dir in versions: + candidate = version_dir / binary_name() + if candidate.exists(): + return candidate + return None + + +def query_latest_version() -> str: + """Query the VS Marketplace for the latest extension version. + + Returns the version string (e.g., '0.4.0'). + """ + payload = { + "filters": [ + { + "criteria": [ + {"filterType": 7, "value": EXTENSION_ID}, + ], + "pageNumber": 1, + "pageSize": 1, + } + ], + "flags": 914, + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json;api-version=6.1-preview.1", + } + + with httpx.Client(timeout=30.0) as client: + resp = client.post(MARKETPLACE_API, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", []) + if not results: + raise RuntimeError("No results from VS Marketplace query") + + extensions = results[0].get("extensions", []) + if not extensions: + raise RuntimeError(f"Extension {EXTENSION_ID} not found on VS Marketplace") + + versions = extensions[0].get("versions", []) + if not versions: + raise RuntimeError(f"No versions found for {EXTENSION_ID}") + + return versions[0]["version"] + + +def download_and_extract(version: str | None = None) -> Path: + """Download the VSIX and extract the server binary. + + Args: + version: Specific version to download. If None, queries latest. + + Returns: + Path to the extracted binary. + """ + if version is None: + print_info("Querying VS Marketplace for latest version...") + version = query_latest_version() + + target_platform = detect_platform() + print_info(f"Downloading pbi-mcp v{version} for {target_platform}...") + + url = VSIX_URL_TEMPLATE.format( + publisher=PUBLISHER, + extension=EXTENSION_NAME, + version=version, + platform=target_platform, + ) + + dest_dir = ensure_home_dir() / "bin" / version + dest_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp: + vsix_path = Path(tmp) / "extension.vsix" + + with httpx.Client(timeout=120.0, follow_redirects=True) as client: + with client.stream("GET", url) as resp: + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + with open(vsix_path, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=8192): + f.write(chunk) + downloaded += len(chunk) + if total > 0: + pct = downloaded * 100 // total + print(f"\r Downloading... {pct}%", end="", flush=True) + print() + + print_info("Extracting server binary...") + with zipfile.ZipFile(vsix_path, "r") as zf: + server_prefix = "extension/server/" + server_files = [n for n in zf.namelist() if n.startswith(server_prefix)] + if not server_files: + raise RuntimeError("No server/ directory found in VSIX package") + + for file_name in server_files: + rel_path = file_name[len(server_prefix):] + if not rel_path: + continue + target_path = dest_dir / rel_path + target_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(file_name) as src, open(target_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + bin_path = dest_dir / binary_name() + if not bin_path.exists(): + raise RuntimeError(f"Binary not found after extraction: {bin_path}") + + ensure_executable(bin_path) + + config = load_config().with_updates( + binary_version=version, + binary_path=str(bin_path), + ) + save_config(config) + + print_success(f"Installed pbi-mcp v{version} at {dest_dir}") + return bin_path + + +def check_for_updates() -> tuple[str, str, bool]: + """Compare installed version with latest available. + + Returns (installed_version, latest_version, update_available). + """ + config = load_config() + installed = config.binary_version or "none" + latest = query_latest_version() + return installed, latest, installed != latest + + +def get_binary_info() -> dict[str, str]: + """Return info about the currently resolved binary.""" + try: + path = resolve_binary() + config = load_config() + return { + "binary_path": str(path), + "version": config.binary_version or "unknown", + "platform": detect_platform(), + "source": _binary_source(path), + } + except FileNotFoundError: + return { + "binary_path": "not found", + "version": "none", + "platform": detect_platform(), + "source": "none", + } + + +def _binary_source(path: Path) -> str: + """Determine the source of a resolved binary path.""" + path_str = str(path) + if "PBI_MCP_BINARY" in os.environ: + return "environment variable (PBI_MCP_BINARY)" + if ".pbi-cli" in path_str: + return "managed (pbi setup)" + if ".vscode" in path_str: + return "VS Code extension (fallback)" + return "unknown" diff --git a/src/pbi_cli/core/config.py b/src/pbi_cli/core/config.py new file mode 100644 index 0000000..5ea4295 --- /dev/null +++ b/src/pbi_cli/core/config.py @@ -0,0 +1,61 @@ +"""Configuration management for pbi-cli. + +Manages ~/.pbi-cli/config.json for binary paths, versions, and preferences. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +PBI_CLI_HOME = Path.home() / ".pbi-cli" +CONFIG_FILE = PBI_CLI_HOME / "config.json" + + +@dataclass(frozen=True) +class PbiConfig: + """Immutable configuration object.""" + + binary_version: str = "" + binary_path: str = "" + default_connection: str = "" + binary_args: list[str] = field(default_factory=lambda: ["--start", "--skipconfirmation"]) + + def with_updates(self, **kwargs: object) -> PbiConfig: + """Return a new config with the specified fields updated.""" + current = asdict(self) + current.update(kwargs) + return PbiConfig(**current) + + +def ensure_home_dir() -> Path: + """Create ~/.pbi-cli/ if it does not exist. Returns the path.""" + PBI_CLI_HOME.mkdir(parents=True, exist_ok=True) + return PBI_CLI_HOME + + +def load_config() -> PbiConfig: + """Load config from disk. Returns defaults if file does not exist.""" + if not CONFIG_FILE.exists(): + return PbiConfig() + try: + raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + return PbiConfig( + binary_version=raw.get("binary_version", ""), + binary_path=raw.get("binary_path", ""), + default_connection=raw.get("default_connection", ""), + binary_args=raw.get("binary_args", ["--start", "--skipconfirmation"]), + ) + except (json.JSONDecodeError, KeyError): + return PbiConfig() + + +def save_config(config: PbiConfig) -> None: + """Write config to disk.""" + ensure_home_dir() + CONFIG_FILE.write_text( + json.dumps(asdict(config), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) diff --git a/src/pbi_cli/core/connection_store.py b/src/pbi_cli/core/connection_store.py new file mode 100644 index 0000000..03b7ea5 --- /dev/null +++ b/src/pbi_cli/core/connection_store.py @@ -0,0 +1,89 @@ +"""Persist named connections to ~/.pbi-cli/connections.json.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + +from pbi_cli.core.config import PBI_CLI_HOME, ensure_home_dir + + +CONNECTIONS_FILE = PBI_CLI_HOME / "connections.json" + + +@dataclass(frozen=True) +class ConnectionInfo: + """A saved connection to a Power BI instance.""" + + name: str + data_source: str + initial_catalog: str = "" + workspace_name: str = "" + semantic_model_name: str = "" + tenant_name: str = "myorg" + connection_string: str = "" + + +@dataclass(frozen=True) +class ConnectionStore: + """Immutable store of named connections.""" + + last_used: str = "" + connections: dict[str, ConnectionInfo] = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.connections is None: + object.__setattr__(self, "connections", {}) + + +def load_connections() -> ConnectionStore: + """Load connections from disk.""" + if not CONNECTIONS_FILE.exists(): + return ConnectionStore() + try: + raw = json.loads(CONNECTIONS_FILE.read_text(encoding="utf-8")) + conns = {} + for name, data in raw.get("connections", {}).items(): + conns[name] = ConnectionInfo(name=name, **{k: v for k, v in data.items() if k != "name"}) + return ConnectionStore( + last_used=raw.get("last_used", ""), + connections=conns, + ) + except (json.JSONDecodeError, KeyError, TypeError): + return ConnectionStore() + + +def save_connections(store: ConnectionStore) -> None: + """Write connections to disk.""" + ensure_home_dir() + data = { + "last_used": store.last_used, + "connections": {name: asdict(info) for name, info in store.connections.items()}, + } + CONNECTIONS_FILE.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +def add_connection(store: ConnectionStore, info: ConnectionInfo) -> ConnectionStore: + """Return a new store with the connection added and set as last-used.""" + new_conns = dict(store.connections) + new_conns[info.name] = info + return ConnectionStore(last_used=info.name, connections=new_conns) + + +def remove_connection(store: ConnectionStore, name: str) -> ConnectionStore: + """Return a new store with the named connection removed.""" + new_conns = {k: v for k, v in store.connections.items() if k != name} + new_last = store.last_used if store.last_used != name else "" + return ConnectionStore(last_used=new_last, connections=new_conns) + + +def get_active_connection(store: ConnectionStore, override: str | None = None) -> ConnectionInfo | None: + """Get the active connection: explicit override, or last-used.""" + name = override or store.last_used + if not name: + return None + return store.connections.get(name) diff --git a/src/pbi_cli/core/mcp_client.py b/src/pbi_cli/core/mcp_client.py new file mode 100644 index 0000000..53614ad --- /dev/null +++ b/src/pbi_cli/core/mcp_client.py @@ -0,0 +1,254 @@ +"""MCP client: communicates with the Power BI MCP server binary over stdio. + +Uses the official `mcp` Python SDK to handle JSON-RPC framing and protocol +negotiation. Exposes a synchronous API for Click commands while managing +an async event loop internally. +""" + +from __future__ import annotations + +import asyncio +import atexit +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from mcp import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + +from pbi_cli.core.binary_manager import resolve_binary +from pbi_cli.core.config import load_config + + +class McpClientError(Exception): + """Raised when the MCP server returns an error.""" + + +class PbiMcpClient: + """Synchronous wrapper around the async MCP stdio client. + + Usage: + client = PbiMcpClient() + result = client.call_tool("measure_operations", { + "operation": "List", + "connectionName": "my-conn", + }) + """ + + def __init__( + self, + binary_path: str | Path | None = None, + args: list[str] | None = None, + ) -> None: + self._binary_path = str(binary_path) if binary_path else None + self._args = args + self._loop: asyncio.AbstractEventLoop | None = None + self._session: ClientSession | None = None + self._cleanup_stack: Any = None + self._started = False + + def _resolve_binary(self) -> str: + """Resolve binary path lazily.""" + if self._binary_path: + return self._binary_path + return str(resolve_binary()) + + def _resolve_args(self) -> list[str]: + """Resolve binary args from config or defaults.""" + if self._args is not None: + return self._args + config = load_config() + return list(config.binary_args) + + def _ensure_loop(self) -> asyncio.AbstractEventLoop: + """Get or create the event loop.""" + if self._loop is None or self._loop.is_closed(): + self._loop = asyncio.new_event_loop() + return self._loop + + def start(self) -> None: + """Start the MCP server process and initialize the session.""" + if self._started: + return + + loop = self._ensure_loop() + loop.run_until_complete(self._async_start()) + self._started = True + atexit.register(self.stop) + + async def _async_start(self) -> None: + """Async startup: spawn the server and initialize MCP session.""" + binary = self._resolve_binary() + args = self._resolve_args() + + server_params = StdioServerParameters( + command=binary, + args=args, + ) + + # Create the stdio transport + self._read_stream, self._write_stream = await self._enter_context( + stdio_client(server_params) + ) + + # Create and initialize the MCP session + self._session = await self._enter_context( + ClientSession(self._read_stream, self._write_stream) + ) + + await self._session.initialize() + + async def _enter_context(self, cm: Any) -> Any: + """Enter an async context manager and track it for cleanup.""" + if self._cleanup_stack is None: + self._cleanup_stack = [] + result = await cm.__aenter__() + self._cleanup_stack.append(cm) + return result + + def call_tool(self, tool_name: str, request: dict[str, Any]) -> Any: + """Call an MCP tool synchronously. + + Args: + tool_name: The MCP tool name (e.g., "measure_operations"). + request: The request dict (will be wrapped as {"request": request}). + + Returns: + The parsed result from the MCP server. + + Raises: + McpClientError: If the server returns an error. + """ + if not self._started: + self.start() + + loop = self._ensure_loop() + return loop.run_until_complete(self._async_call_tool(tool_name, request)) + + async def _async_call_tool(self, tool_name: str, request: dict[str, Any]) -> Any: + """Execute a tool call via the MCP session.""" + if self._session is None: + raise McpClientError("MCP session not initialized. Call start() first.") + + result = await self._session.call_tool( + tool_name, + arguments={"request": request}, + ) + + if result.isError: + error_text = _extract_text(result.content) + raise McpClientError(f"MCP tool error: {error_text}") + + return _parse_content(result.content) + + def list_tools(self) -> list[dict[str, Any]]: + """List all available MCP tools.""" + if not self._started: + self.start() + + loop = self._ensure_loop() + return loop.run_until_complete(self._async_list_tools()) + + async def _async_list_tools(self) -> list[dict[str, Any]]: + """List tools from the MCP session.""" + if self._session is None: + raise McpClientError("MCP session not initialized.") + + result = await self._session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description or "", + } + for tool in result.tools + ] + + def stop(self) -> None: + """Shut down the MCP server process.""" + if not self._started: + return + + loop = self._ensure_loop() + loop.run_until_complete(self._async_stop()) + self._started = False + self._session = None + + async def _async_stop(self) -> None: + """Clean up all async context managers in reverse order.""" + if self._cleanup_stack: + for cm in reversed(self._cleanup_stack): + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + self._cleanup_stack = [] + + def __del__(self) -> None: + try: + self.stop() + except Exception: + pass + + +def _extract_text(content: Any) -> str: + """Extract text from MCP content blocks.""" + if isinstance(content, list): + parts = [] + for block in content: + if hasattr(block, "text"): + parts.append(block.text) + return "\n".join(parts) if parts else str(content) + return str(content) + + +def _parse_content(content: Any) -> Any: + """Parse MCP content blocks into Python data. + + MCP returns content as a list of TextContent blocks. This function + tries to parse the text as JSON, falling back to raw text. + """ + import json + + if isinstance(content, list): + texts = [] + for block in content: + if hasattr(block, "text"): + texts.append(block.text) + + if len(texts) == 1: + try: + return json.loads(texts[0]) + except (json.JSONDecodeError, ValueError): + return texts[0] + + combined = "\n".join(texts) + try: + return json.loads(combined) + except (json.JSONDecodeError, ValueError): + return combined + + return content + + +# Module-level singleton for REPL mode (keeps server alive across commands). +_shared_client: PbiMcpClient | None = None + + +def get_shared_client() -> PbiMcpClient: + """Get or create a shared MCP client instance.""" + global _shared_client + if _shared_client is None: + _shared_client = PbiMcpClient() + return _shared_client + + +def get_client(repl_mode: bool = False) -> PbiMcpClient: + """Get an MCP client. + + In REPL mode, returns a shared long-lived client. + In one-shot mode, returns a fresh client (caller should stop() it). + """ + if repl_mode: + return get_shared_client() + return PbiMcpClient() diff --git a/src/pbi_cli/core/output.py b/src/pbi_cli/core/output.py new file mode 100644 index 0000000..7257892 --- /dev/null +++ b/src/pbi_cli/core/output.py @@ -0,0 +1,89 @@ +"""Dual-mode output formatter: JSON for agents, Rich tables for humans.""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + + +console = Console() +error_console = Console(stderr=True) + + +def print_json(data: Any) -> None: + """Print data as formatted JSON to stdout.""" + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + + +def print_success(message: str) -> None: + """Print a success message to stderr (keeps stdout clean for JSON).""" + error_console.print(f"[green]{message}[/green]") + + +def print_error(message: str) -> None: + """Print an error message to stderr.""" + error_console.print(f"[red]Error:[/red] {message}") + + +def print_warning(message: str) -> None: + """Print a warning message to stderr.""" + error_console.print(f"[yellow]Warning:[/yellow] {message}") + + +def print_info(message: str) -> None: + """Print an info message to stderr.""" + error_console.print(f"[blue]{message}[/blue]") + + +def print_table( + title: str, + columns: list[str], + rows: list[list[str]], +) -> None: + """Print a Rich table to stdout.""" + table = Table(title=title, show_header=True, header_style="bold cyan") + for col in columns: + table.add_column(col) + for row in rows: + table.add_row(*row) + console.print(table) + + +def print_key_value(title: str, data: dict[str, Any]) -> None: + """Print key-value pairs in a Rich panel.""" + lines = [] + for key, value in data.items(): + lines.append(f"[bold]{key}:[/bold] {value}") + console.print(Panel("\n".join(lines), title=title, border_style="cyan")) + + +def format_mcp_result(result: Any, json_output: bool) -> None: + """Format and print an MCP tool result. + + In JSON mode, prints the raw result. In human mode, attempts to render + a table or key-value display based on the shape of the data. + """ + if json_output: + print_json(result) + return + + if isinstance(result, list): + if not result: + print_info("No results.") + return + if isinstance(result[0], dict): + columns = list(result[0].keys()) + rows = [[str(item.get(c, "")) for c in columns] for item in result] + print_table("Results", columns, rows) + else: + for item in result: + console.print(str(item)) + elif isinstance(result, dict): + print_key_value("Result", result) + else: + console.print(str(result)) diff --git a/src/pbi_cli/main.py b/src/pbi_cli/main.py new file mode 100644 index 0000000..d697efa --- /dev/null +++ b/src/pbi_cli/main.py @@ -0,0 +1,87 @@ +"""Main CLI entry point for pbi-cli.""" + +from __future__ import annotations + +import sys +from typing import Any + +import click + +from pbi_cli import __version__ + + +class PbiContext: + """Shared context passed to all CLI commands.""" + + def __init__(self, json_output: bool = False, connection: str | None = None) -> None: + self.json_output = json_output + self.connection = connection + + +pass_context = click.make_pass_decorator(PbiContext, ensure=True) + + +@click.group() +@click.option("--json", "json_output", is_flag=True, default=False, help="Output raw JSON for agent consumption.") +@click.option("--connection", "-c", default=None, help="Named connection to use (defaults to last-used).") +@click.version_option(version=__version__, prog_name="pbi-cli") +@click.pass_context +def cli(ctx: click.Context, json_output: bool, connection: str | None) -> None: + """pbi-cli: Power BI semantic model CLI. + + Wraps the Power BI MCP server for token-efficient usage with + Claude Code and other AI agents. + + Run 'pbi setup' first to download the Power BI MCP binary. + """ + ctx.ensure_object(PbiContext) + ctx.obj = PbiContext(json_output=json_output, connection=connection) + + +def _register_commands() -> None: + """Lazily import and register all command groups.""" + from pbi_cli.commands.setup_cmd import setup + from pbi_cli.commands.connection import connect, connect_fabric, disconnect, connections + from pbi_cli.commands.dax import dax + from pbi_cli.commands.measure import measure + from pbi_cli.commands.table import table + from pbi_cli.commands.column import column + from pbi_cli.commands.relationship import relationship + from pbi_cli.commands.model import model + from pbi_cli.commands.database import database + from pbi_cli.commands.security import security_role + from pbi_cli.commands.calc_group import calc_group + from pbi_cli.commands.partition import partition + from pbi_cli.commands.perspective import perspective + from pbi_cli.commands.hierarchy import hierarchy + from pbi_cli.commands.expression import expression + from pbi_cli.commands.calendar import calendar + from pbi_cli.commands.trace import trace + from pbi_cli.commands.transaction import transaction + from pbi_cli.commands.advanced import advanced + + cli.add_command(setup) + cli.add_command(connect) + cli.add_command(connect_fabric) + cli.add_command(disconnect) + cli.add_command(connections) + cli.add_command(dax) + cli.add_command(measure) + cli.add_command(table) + cli.add_command(column) + cli.add_command(relationship) + cli.add_command(model) + cli.add_command(database) + cli.add_command(security_role) + cli.add_command(calc_group) + cli.add_command(partition) + cli.add_command(perspective) + cli.add_command(hierarchy) + cli.add_command(expression) + cli.add_command(calendar) + cli.add_command(trace) + cli.add_command(transaction) + cli.add_command(advanced) + + +_register_commands() diff --git a/src/pbi_cli/utils/__init__.py b/src/pbi_cli/utils/__init__.py new file mode 100644 index 0000000..a9f02a4 --- /dev/null +++ b/src/pbi_cli/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for pbi-cli.""" diff --git a/src/pbi_cli/utils/platform.py b/src/pbi_cli/utils/platform.py new file mode 100644 index 0000000..1a57a96 --- /dev/null +++ b/src/pbi_cli/utils/platform.py @@ -0,0 +1,83 @@ +"""Platform and architecture detection for binary resolution.""" + +from __future__ import annotations + +import os +import platform +import stat +from pathlib import Path + + +# Maps (system, machine) to VS Marketplace target platform identifier. +PLATFORM_MAP: dict[tuple[str, str], str] = { + ("Windows", "AMD64"): "win32-x64", + ("Windows", "x86_64"): "win32-x64", + ("Windows", "ARM64"): "win32-arm64", + ("Darwin", "arm64"): "darwin-arm64", + ("Linux", "x86_64"): "linux-x64", + ("Linux", "aarch64"): "linux-arm64", +} + +# Binary name per OS. +BINARY_NAMES: dict[str, str] = { + "Windows": "powerbi-modeling-mcp.exe", + "Darwin": "powerbi-modeling-mcp", + "Linux": "powerbi-modeling-mcp", +} + + +def detect_platform() -> str: + """Return the VS Marketplace target platform string for this machine. + + Raises ValueError if the platform is unsupported. + """ + system = platform.system() + machine = platform.machine() + key = (system, machine) + target = PLATFORM_MAP.get(key) + if target is None: + raise ValueError( + f"Unsupported platform: {system}/{machine}. " + f"Supported: {', '.join(f'{s}/{m}' for s, m in PLATFORM_MAP)}" + ) + return target + + +def binary_name() -> str: + """Return the expected binary filename for this OS.""" + system = platform.system() + name = BINARY_NAMES.get(system) + if name is None: + raise ValueError(f"Unsupported OS: {system}") + return name + + +def ensure_executable(path: Path) -> None: + """Set executable permission on non-Windows systems.""" + if platform.system() != "Windows": + current = path.stat().st_mode + path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def find_vscode_extension_binary() -> Path | None: + """Look for the binary in the VS Code extension install directory. + + This is the fallback resolution path when the user has the VS Code + extension installed but hasn't run 'pbi setup'. + """ + vscode_ext_dir = Path.home() / ".vscode" / "extensions" + if not vscode_ext_dir.exists(): + return None + + matches = sorted( + vscode_ext_dir.glob("analysis-services.powerbi-modeling-mcp-*/server"), + reverse=True, + ) + if not matches: + return None + + server_dir = matches[0] + bin_path = server_dir / binary_name() + if bin_path.exists(): + return bin_path + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_commands/__init__.py b/tests/test_commands/__init__.py new file mode 100644 index 0000000..e69de29