diff --git a/pyproject.toml b/pyproject.toml index 9cdb26f..a465303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pbi-cli-tool" -version = "2.0.0" +version = "2.1.0" description = "CLI for Power BI semantic models - direct .NET connection for token-efficient AI agent usage" readme = "README.pypi.md" license = {text = "MIT"} diff --git a/src/pbi_cli/__init__.py b/src/pbi_cli/__init__.py index 25c669a..648d00c 100644 --- a/src/pbi_cli/__init__.py +++ b/src/pbi_cli/__init__.py @@ -1,3 +1,3 @@ """pbi-cli: CLI for Power BI semantic models via direct .NET interop.""" -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/src/pbi_cli/commands/connection.py b/src/pbi_cli/commands/connection.py index bb740c4..967a385 100644 --- a/src/pbi_cli/commands/connection.py +++ b/src/pbi_cli/commands/connection.py @@ -194,6 +194,7 @@ def _ensure_ready() -> None: pbi connect -d localhost:54321 """ from pbi_cli.commands.skills_cmd import SKILLS_TARGET_DIR, _get_bundled_skills + from pbi_cli.core.claude_integration import ensure_claude_md_snippet bundled = _get_bundled_skills() any_missing = any(not (SKILLS_TARGET_DIR / name / "SKILL.md").exists() for name in bundled) @@ -208,3 +209,5 @@ def _ensure_ready() -> None: target_file = target_dir / "SKILL.md" target_file.write_text(source_file.read_text(encoding="utf-8"), encoding="utf-8") print_info("Skills installed.") + + ensure_claude_md_snippet() diff --git a/src/pbi_cli/commands/skills_cmd.py b/src/pbi_cli/commands/skills_cmd.py index 883a45b..28a3801 100644 --- a/src/pbi_cli/commands/skills_cmd.py +++ b/src/pbi_cli/commands/skills_cmd.py @@ -115,3 +115,9 @@ def skills_uninstall(ctx: object, skill_name: str | None) -> None: click.echo(f" {name}: removed", err=True) click.echo(f"\n{removed_count} skill(s) removed.", err=True) + + # Remove CLAUDE.md snippet when uninstalling all skills + if skill_name is None: + from pbi_cli.core.claude_integration import remove_claude_md_snippet + + remove_claude_md_snippet() diff --git a/src/pbi_cli/core/claude_integration.py b/src/pbi_cli/core/claude_integration.py new file mode 100644 index 0000000..22019ce --- /dev/null +++ b/src/pbi_cli/core/claude_integration.py @@ -0,0 +1,72 @@ +"""CLAUDE.md snippet management for Claude Code integration.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +CLAUDE_MD_PATH = Path.home() / ".claude" / "CLAUDE.md" + +_PBI_CLI_MARKER_START = "" +_PBI_CLI_MARKER_END = "" + +_PBI_CLI_CLAUDE_MD_SNIPPET = ( + "\n" + "\n" + "# Power BI CLI (pbi-cli)\n" + "\n" + "When working with Power BI, DAX, semantic models, or data modeling,\n" + "invoke the relevant pbi-cli skill before responding:\n" + "\n" + "- **power-bi-dax** -- DAX queries, measures, calculations\n" + "- **power-bi-modeling** -- tables, columns, measures, relationships\n" + "- **power-bi-diagnostics** -- troubleshooting, tracing, setup\n" + "- **power-bi-deployment** -- TMDL export/import, transactions\n" + "- **power-bi-docs** -- model documentation, data dictionary\n" + "- **power-bi-partitions** -- partitions, M expressions, data sources\n" + "- **power-bi-security** -- RLS roles, perspectives, access control\n" + "\n" + "Critical: Multi-line DAX (VAR/RETURN) cannot be passed via `-e`.\n" + "Use `--file` or stdin piping instead. See power-bi-dax skill.\n" + "\n" +) + + +def ensure_claude_md_snippet() -> None: + """Append pbi-cli section to ~/.claude/CLAUDE.md if not already present.""" + if CLAUDE_MD_PATH.exists(): + existing = CLAUDE_MD_PATH.read_text(encoding="utf-8") + if _PBI_CLI_MARKER_START in existing: + return # Already present + else: + CLAUDE_MD_PATH.parent.mkdir(parents=True, exist_ok=True) + existing = "" + + new_content = existing.rstrip() + _PBI_CLI_CLAUDE_MD_SNIPPET + CLAUDE_MD_PATH.write_text(new_content, encoding="utf-8") + click.echo(" Added pbi-cli section to ~/.claude/CLAUDE.md", err=True) + + +def remove_claude_md_snippet() -> None: + """Remove pbi-cli section from ~/.claude/CLAUDE.md if present.""" + if not CLAUDE_MD_PATH.exists(): + return + + content = CLAUDE_MD_PATH.read_text(encoding="utf-8") + if _PBI_CLI_MARKER_START not in content: + return + + start_idx = content.index(_PBI_CLI_MARKER_START) + end_idx = content.index(_PBI_CLI_MARKER_END) + len(_PBI_CLI_MARKER_END) + + before = content[:start_idx].rstrip() + after = content[end_idx:].lstrip("\n") + + cleaned = before + if after: + cleaned = before + "\n\n" + after + cleaned = cleaned.rstrip() + "\n" if cleaned.strip() else "" + + CLAUDE_MD_PATH.write_text(cleaned, encoding="utf-8") + click.echo(" Removed pbi-cli section from ~/.claude/CLAUDE.md", err=True) diff --git a/src/pbi_cli/skills/power-bi-dax/SKILL.md b/src/pbi_cli/skills/power-bi-dax/SKILL.md index dd65832..bf0d35b 100644 --- a/src/pbi_cli/skills/power-bi-dax/SKILL.md +++ b/src/pbi_cli/skills/power-bi-dax/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI DAX -description: Write, execute, and optimize DAX queries and measures for Power BI semantic models. Use when the user mentions DAX, Power BI calculations, querying data, or wants to analyze data in a semantic model. +description: Write, execute, and optimize DAX queries and measures for Power BI semantic models using pbi-cli. Invoke this skill whenever the user mentions DAX, queries data in Power BI, writes calculations, creates measures, asks about EVALUATE, SUMMARIZECOLUMNS, CALCULATE, time intelligence, or wants to analyze/aggregate data from a semantic model. Also invoke when the user asks to run a query, test a formula, or check row counts. This skill contains critical guidance on passing DAX expressions via CLI arguments -- multi-line DAX (VAR/RETURN) requires special handling. tools: pbi-cli --- @@ -36,6 +36,47 @@ pbi dax execute "EVALUATE Sales" --timeout 300 # Custom timeout (seconds) pbi --json dax execute "EVALUATE Sales" ``` +## DAX Expression Limitations in CLI + +When passing DAX as a `-e` argument, the shell collapses newlines into a single line. Simple expressions like `SUM(Sales[Amount])` work fine, but multi-line DAX using VAR/RETURN breaks because the DAX parser needs line breaks between those keywords. + +**Why this matters:** A measure like `VAR x = [Total Sales] VAR y = [Sales PY] RETURN DIVIDE(x - y, y)` will fail with a syntax error because the engine sees it as one continuous line without statement separators. + +**Workarounds (pick one):** + +```bash +# Option 1: Pipe from stdin (recommended for measures) +echo 'VAR TotalSales = SUM(Sales[Amount]) +VAR TotalCost = SUM(Sales[Cost]) +RETURN TotalSales - TotalCost' | pbi measure create "Profit" -e - -t Sales + +# Option 2: Write to a .dax file and use --file (for queries) +echo 'EVALUATE +ROW("Result", + VAR x = SUM(Sales[Amount]) + RETURN x +)' > query.dax +pbi dax execute --file query.dax +``` + +**Single-line alternatives (preferred when possible):** + +For simple ratio/growth measures, use inline patterns instead of VAR/RETURN: + +```bash +# Instead of: VAR x = SUM(...) / VAR y = SUM(...) / RETURN DIVIDE(x, y) +# Use inline DIVIDE -- it handles division-by-zero gracefully (returns BLANK): +pbi measure create "Margin %" \ + -e "DIVIDE(SUM(Sales[Amount]) - SUM(Sales[Cost]), SUM(Sales[Amount]))" \ + -t Sales --format-string "0.0%" + +# Instead of: VAR current = [Total Sales] / VAR prev = [Sales PY] / RETURN DIVIDE(...) +# Reference measures directly in DIVIDE: +pbi measure create "YoY %" \ + -e "DIVIDE([Total Sales] - [PY Sales], [PY Sales])" \ + -t Sales --format-string "0.0%" +``` + ## Validating Queries ```bash diff --git a/src/pbi_cli/skills/power-bi-deployment/SKILL.md b/src/pbi_cli/skills/power-bi-deployment/SKILL.md index 80b0fbb..651be51 100644 --- a/src/pbi_cli/skills/power-bi-deployment/SKILL.md +++ b/src/pbi_cli/skills/power-bi-deployment/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Deployment -description: Import and export TMDL and TMSL formats, manage model lifecycle with transactions, and version-control Power BI semantic models. Use when the user mentions deploying, publishing, migrating, or version-controlling Power BI models. +description: Import and export TMDL/TMSL formats, manage model lifecycle with transactions, and version-control Power BI semantic models using pbi-cli. Invoke this skill whenever the user mentions "deploy", "export", "import", "TMDL", "TMSL", "version control", "git", "backup", "migrate", "transaction", "commit changes", "rollback", or wants to save/restore model state. tools: pbi-cli --- diff --git a/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md b/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md index 049ecfa..63a5d4a 100644 --- a/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md +++ b/src/pbi_cli/skills/power-bi-diagnostics/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Diagnostics -description: Troubleshoot Power BI model performance, trace query execution, manage caches, and verify the pbi-cli environment. Use when the user mentions slow queries, performance issues, tracing, profiling, or setup problems. +description: Troubleshoot Power BI model performance, trace query execution, manage caches, and verify the pbi-cli environment using pbi-cli. Invoke this skill whenever the user says "pbi not working", "setup issues", "connection failed", "slow query", "performance", "profiling", "tracing", "health check", "model audit", "pbi setup", or encounters any pbi-cli error. This is the first skill to check when something goes wrong with pbi-cli. tools: pbi-cli --- @@ -29,6 +29,25 @@ pbi --json setup --info pbi --version ``` +## Quick Troubleshooting + +If pbi-cli isn't working, run these checks in order: + +```bash +# 1. Is pbi-cli installed correctly? +pbi --version +pbi setup --info + +# 2. Is Power BI Desktop running with a model open? +pbi connect + +# 3. Is the connection still alive? +pbi connections last + +# 4. Can you query the model? +pbi dax execute "EVALUATE ROW(\"test\", 1)" +``` + ## Model Health Check ```bash diff --git a/src/pbi_cli/skills/power-bi-docs/SKILL.md b/src/pbi_cli/skills/power-bi-docs/SKILL.md index db31a64..3048c19 100644 --- a/src/pbi_cli/skills/power-bi-docs/SKILL.md +++ b/src/pbi_cli/skills/power-bi-docs/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Documentation -description: Auto-document Power BI semantic models by extracting metadata, generating comprehensive documentation, and cataloging all model objects. Use when the user wants to document a Power BI model, create a data dictionary, or audit model contents. +description: Auto-document Power BI semantic models by extracting metadata, generating documentation, and cataloging all model objects using pbi-cli. Invoke this skill whenever the user says "document this model", "what's in this model", "list everything", "data dictionary", "model inventory", "audit contents", "catalog", "describe the model", or wants to understand what objects exist in a semantic model. tools: pbi-cli --- diff --git a/src/pbi_cli/skills/power-bi-modeling/SKILL.md b/src/pbi_cli/skills/power-bi-modeling/SKILL.md index 371558d..e26d5dc 100644 --- a/src/pbi_cli/skills/power-bi-modeling/SKILL.md +++ b/src/pbi_cli/skills/power-bi-modeling/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Modeling -description: Create and manage Power BI semantic model objects including tables, columns, measures, relationships, hierarchies, and calculation groups. Use when the user mentions Power BI modeling, semantic models, or wants to create or modify model objects. +description: Create and manage Power BI semantic model structure using pbi-cli -- tables, columns, measures, relationships, hierarchies, calculation groups, and date/calendar tables. Invoke this skill whenever the user says "create table", "add measure", "add column", "create relationship", "date table", "calendar table", "star schema", "mark as date table", "add hierarchy", "calculation group", or any model-building task. Also invoke when creating multiple measures at once -- the skill contains critical guidance on multi-line DAX expression handling. tools: pbi-cli --- @@ -54,6 +54,16 @@ pbi measure rename "Old" "New" -t Sales # Rename pbi measure move "Revenue" -t Sales --to-table Finance # Move to another table ``` +**Multi-line DAX in measure expressions:** The `-e` flag passes DAX as a shell argument, which collapses newlines. For simple expressions like `SUM(Sales[Amount])` or `DIVIDE([A] - [B], [B])` this works fine. For complex expressions using VAR/RETURN, pipe from stdin instead: + +```bash +echo 'VAR TotalSales = SUM(Sales[Amount]) +VAR TotalCost = SUM(Sales[Cost]) +RETURN TotalSales - TotalCost' | pbi measure create "Profit" -e - -t Sales +``` + +See the **power-bi-dax** skill for the full explanation and more workarounds. + ## Relationships ```bash @@ -89,6 +99,22 @@ pbi calc-group create-item "YTD" \ pbi calc-group delete "Time Intelligence" # Delete group ``` +## Creating a Date/Calendar Table + +Date tables are essential for time intelligence functions (TOTALYTD, SAMEPERIODLASTYEAR, DATEADD, etc.). + +```bash +# Create a calculated date table with DAX (covers full calendar years) +pbi table create Calendar \ + --dax-expression "ADDCOLUMNS(CALENDAR(DATE(2023,1,1), DATE(2024,12,31)), \"Year\", YEAR([Date]), \"MonthNumber\", MONTH([Date]), \"MonthName\", FORMAT([Date], \"MMMM\"), \"Quarter\", \"Q\" & FORMAT([Date], \"Q\"))" + +# Mark it as a date table (required for time intelligence) +pbi table mark-date Calendar --date-column Date + +# Verify it's recognized as a date table +pbi calendar list +``` + ## Workflow: Create a Star Schema ```bash diff --git a/src/pbi_cli/skills/power-bi-partitions/SKILL.md b/src/pbi_cli/skills/power-bi-partitions/SKILL.md index c92d1b2..907a3dc 100644 --- a/src/pbi_cli/skills/power-bi-partitions/SKILL.md +++ b/src/pbi_cli/skills/power-bi-partitions/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Partitions & Expressions -description: Manage Power BI table partitions, named expressions (M/Power Query sources), and calendar table configuration. Use when the user mentions partitions, data sources, M expressions, Power Query, incremental refresh, or calendar/date tables. +description: Manage Power BI table partitions, named expressions (M/Power Query data sources), and calendar table configuration using pbi-cli. Invoke this skill whenever the user mentions "partitions", "data sources", "M expressions", "Power Query", "incremental refresh", "named expressions", "connection parameters", or wants to configure how tables load data. For broader modeling tasks (measures, relationships, hierarchies), see power-bi-modeling instead. tools: pbi-cli --- diff --git a/src/pbi_cli/skills/power-bi-security/SKILL.md b/src/pbi_cli/skills/power-bi-security/SKILL.md index 318ed15..02c7261 100644 --- a/src/pbi_cli/skills/power-bi-security/SKILL.md +++ b/src/pbi_cli/skills/power-bi-security/SKILL.md @@ -1,6 +1,6 @@ --- name: Power BI Security -description: Configure row-level security (RLS) roles, manage object-level security, and set up perspectives for Power BI semantic models. Use when the user mentions Power BI security, RLS, access control, or data restrictions. +description: Configure row-level security (RLS) roles, object-level security, and perspectives for Power BI semantic models using pbi-cli. Invoke this skill whenever the user mentions "security", "RLS", "row-level security", "access control", "data restrictions", "who can see", "filter by user", "perspectives", "limit visibility", or wants to restrict data access by role. tools: pbi-cli --- diff --git a/tests/test_claude_md.py b/tests/test_claude_md.py new file mode 100644 index 0000000..8470315 --- /dev/null +++ b/tests/test_claude_md.py @@ -0,0 +1,103 @@ +"""Tests for CLAUDE.md snippet injection and removal.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_claude_md(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect CLAUDE_MD_PATH to a temp directory.""" + claude_md = tmp_path / ".claude" / "CLAUDE.md" + monkeypatch.setattr("pbi_cli.core.claude_integration.CLAUDE_MD_PATH", claude_md) + return claude_md + + +def _get_funcs(): + """Lazy import to avoid circular import at collection time.""" + from pbi_cli.core.claude_integration import ( + _PBI_CLI_MARKER_END, + _PBI_CLI_MARKER_START, + ensure_claude_md_snippet, + remove_claude_md_snippet, + ) + + return ( + ensure_claude_md_snippet, + remove_claude_md_snippet, + _PBI_CLI_MARKER_START, + _PBI_CLI_MARKER_END, + ) + + +class TestEnsureClaudeMdSnippet: + def test_creates_file_when_missing(self, tmp_claude_md: Path) -> None: + ensure, _, start, end = _get_funcs() + assert not tmp_claude_md.exists() + ensure() + assert tmp_claude_md.exists() + content = tmp_claude_md.read_text(encoding="utf-8") + assert start in content + assert end in content + assert "power-bi-dax" in content + + def test_appends_to_existing(self, tmp_claude_md: Path) -> None: + ensure, _, start, end = _get_funcs() + tmp_claude_md.parent.mkdir(parents=True, exist_ok=True) + tmp_claude_md.write_text("# My Preferences\n\n- No em dashes\n", encoding="utf-8") + ensure() + content = tmp_claude_md.read_text(encoding="utf-8") + assert content.startswith("# My Preferences") + assert "- No em dashes" in content + assert start in content + assert end in content + + def test_is_idempotent(self, tmp_claude_md: Path) -> None: + ensure, _, _, _ = _get_funcs() + tmp_claude_md.parent.mkdir(parents=True, exist_ok=True) + tmp_claude_md.write_text("# Existing\n", encoding="utf-8") + ensure() + first_content = tmp_claude_md.read_text(encoding="utf-8") + ensure() + second_content = tmp_claude_md.read_text(encoding="utf-8") + assert first_content == second_content + + +class TestRemoveClaudeMdSnippet: + def test_removes_snippet(self, tmp_claude_md: Path) -> None: + ensure, remove, start, end = _get_funcs() + tmp_claude_md.parent.mkdir(parents=True, exist_ok=True) + tmp_claude_md.write_text("# Existing\n", encoding="utf-8") + ensure() + assert start in tmp_claude_md.read_text(encoding="utf-8") + remove() + content = tmp_claude_md.read_text(encoding="utf-8") + assert start not in content + assert end not in content + + def test_preserves_other_content(self, tmp_claude_md: Path) -> None: + ensure, remove, start, _ = _get_funcs() + tmp_claude_md.parent.mkdir(parents=True, exist_ok=True) + tmp_claude_md.write_text("# My Preferences\n\n- No em dashes\n", encoding="utf-8") + ensure() + remove() + content = tmp_claude_md.read_text(encoding="utf-8") + assert "# My Preferences" in content + assert "- No em dashes" in content + assert start not in content + + def test_noop_when_not_present(self, tmp_claude_md: Path) -> None: + _, remove, _, _ = _get_funcs() + tmp_claude_md.parent.mkdir(parents=True, exist_ok=True) + original = "# My Preferences\n\n- No em dashes\n" + tmp_claude_md.write_text(original, encoding="utf-8") + remove() + assert tmp_claude_md.read_text(encoding="utf-8") == original + + def test_noop_when_file_missing(self, tmp_claude_md: Path) -> None: + _, remove, _, _ = _get_funcs() + assert not tmp_claude_md.exists() + remove() # Should not raise + assert not tmp_claude_md.exists()