diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..47126be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# AGENTS.md — bcli for AI coding agents + +This file is for AI coding agents (Claude Code, Cursor, OpenAI Codex, +etc.) driving `bcli` on a user's behalf. If you're a human, you can +read it too, but the much friendlier intro is +[`docs/getting-started.md`](docs/getting-started.md). + +The goal here is simple: **get to the right command in 1–2 tool calls, +not 5**. Most of the time agents stumble on `bcli` because they guess +endpoint names, guess field names, or pass redundant flags. The recipes +below short-circuit those guesses. + +--- + +## Profiles do most of the work — don't override them + +A profile (`my-profile`) already encodes: + +- `tenant_id`, `client_id` (auth) +- `environment` (e.g. `Production`, `Sandbox`) +- `company_id` (default company) +- `auth_method` (`browser`, `device_code`, `client_credentials`, …) + +So `bcli --profile my-profile get vendors --top 5` is enough. You do +**not** need to also pass `-e Production` — the profile already knows. +Use `-e` / `--env` only when the user explicitly asks you to hit a +*different* environment than the profile's default. + +Same for `--company` / `-c`: only pass it when switching off the +profile's default company. + +--- + +## Endpoint discovery — don't guess names + +The custom registry an organization installs may be a curated subset of +BC's catalog. Names are case-sensitive and not always plural-of-the- +obvious-singular (e.g. `preservationStatuses`, not `preservationStatus`). +The recipe: + +```bash +bcli --profile my-profile endpoint search +bcli --profile my-profile endpoint info -f json +bcli --profile my-profile endpoint list -f json # if you need everything +``` + +`endpoint search` does fuzzy matching against name + description. +`endpoint info` returns structured metadata (route, key field, +operations, cached field names if any). `endpoint list -f json` is the +machine-parseable enumeration. + +**Avoid `endpoint list` without `-f json` from an agent**: the table +format truncates wide columns, especially on narrow terminals. The +markdown / json formats render every column in full. + +--- + +## Field discovery — don't guess fields either + +BC custom-API field names sometimes look nothing like the column you'd +expect (`serialNo` rather than `serialNumber`, `no` rather than +`number`). Don't pass `--filter " eq 'X'"` and hope for the +best — that's 1–2 wasted tool calls per guess. + +The canonical command: + +```bash +bcli --profile my-profile endpoint fields +``` + +It fetches one record from the endpoint, prints every field name with +its inferred type and a sample value, and persists those names to the +custom registry so subsequent `--filter` validation can suggest the +right field when you mistype. One API call up front saves three round +trips later. + +--- + +## Output formats — pick on purpose + +| Format | When | +|---|---| +| `markdown` | Default for AI agents (Claude Code, etc.) and non-TTY stdout. Renders every column, no ANSI escapes. | +| `json` | Programmatic parsing with `jq`. Use when you need to feed the result back into another tool call. | +| `table` | Interactive humans on a real terminal. Avoid from agents — rich truncates wide columns. | +| `csv` / `ndjson` | Bulk export, downstream pipelines. | +| `raw` | Untransformed BC payload (debug only). | + +Agent auto-detection: if `CLAUDECODE=1` or `BCLI_AGENT=1` is set, or +stdout isn't a TTY, `bcli` already defaults to `markdown` without you +asking. Same on classic Windows PowerShell (where rich's box-drawing +otherwise renders as `�` mojibake). + +--- + +## Common errors — what they mean and what to run next + +### `RegistryError: Endpoint 'X' is not in this profile's custom registry` + +The profile has `disable_standard_api = true` and `X` isn't in the +curated list. The error message itself now includes a `Did you mean:` +suggestion when the name is close to a known one. To explore further: + +```bash +bcli --profile my-profile endpoint search +bcli --profile my-profile endpoint list -f json +``` + +### `HTTP 400 Bad Request: Could not find a property named 'X' on type ...` + +Field name doesn't exist on this entity. The error message now includes +a `Hint: bcli endpoint fields ` line — run that, look at the +real field names, retry the query. + +### `HTTP 403 Forbidden` + +The user's BC permission set denies this endpoint or row. This is a +server-side decision and there's nothing client-side to retry. Tell the +user, don't loop. + +--- + +## When you have an MCP server + +If the user has mounted `bcli-mcp` (see [`docs/mcp-server.md`](docs/mcp-server.md)), +prefer those tools — they collapse discovery + query into single calls +with structured results: + +- `list_endpoints()` — full registry as JSON +- `describe_endpoint(name, discover_fields=True)` — metadata + field + discovery in one call +- `query(endpoint, filter, ...)` — the same as `bcli get`, but typed + +The CLI recipes above still work fine if the MCP server isn't +available; this is purely an "if you've got it, use it" optimization. + +--- + +## Quick decision flow + +``` +User asks: "find X for entity Y" + │ + ├─ Do you know the endpoint name? ──── No ──→ bcli endpoint search Y + │ │ + │ Yes + │ │ + ├─ Do you know the field names? ──── No ──→ bcli endpoint fields + │ │ + │ Yes + │ │ + └─ bcli --profile

get --filter " eq ''" -f json +``` + +Three tool calls in the worst case (search → fields → get), one in the +best case (just get). Every other shape is a guess. diff --git a/README.md b/README.md index 2ac6ca4..6e778c1 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ pip install -e ".[dev,etl]" | [SDK Usage](docs/sdk-usage.md) | Python SDK for developers and MCP servers | | [MCP Server](docs/mcp-server.md) | Drive bcli from Claude Desktop via the `bcli-mcp` server (preview) | | [Command Reference](docs/command-reference.md) | Complete CLI command reference | +| [For AI Agents](AGENTS.md) | Quick discovery recipes for Claude Code, Cursor, etc. driving bcli on a user's behalf | | [Contributing](docs/contributing.md) | Development setup, architecture, testing | ## License diff --git a/src/bcli/client/_async.py b/src/bcli/client/_async.py index 3f942fd..bfdd124 100644 --- a/src/bcli/client/_async.py +++ b/src/bcli/client/_async.py @@ -479,12 +479,23 @@ def _resolve_url_for_target( if endpoint is None and self._profile.disable_standard_api: from bcli.errors import RegistryError + # Best-effort fuzzy suggestion. Cheap (in-memory registry scan) + # and saves AI agents a `bcli endpoint list` round trip when + # they're one typo away (e.g. preservationStatus → + # preservationStatuses). + suggestions = self._registry.search(entity_set_name)[:3] + did_you_mean = "" + if suggestions: + names = ", ".join(s.entity_set_name for s in suggestions) + did_you_mean = f" Did you mean: {names}?" + raise RegistryError( f"Endpoint '{entity_set_name}' is not in this profile's " f"custom registry, and 'disable_standard_api = true' blocks " - f"the standard v2.0 fallback. " - f"Run 'bcli endpoint list' to see what is available, " - f"'bcli registry import' to add a new one, " + f"the standard v2.0 fallback.{did_you_mean} " + f"Try 'bcli endpoint search ' to find similar names, " + f"'bcli endpoint list -f json' for the full machine-readable " + f"list, 'bcli registry import' to add a new one, " f"or pass --publisher/--group/--version to override." ) diff --git a/src/bcli/client/_transport.py b/src/bcli/client/_transport.py index ed1c2d8..a62f484 100644 --- a/src/bcli/client/_transport.py +++ b/src/bcli/client/_transport.py @@ -4,6 +4,7 @@ import json import logging +import re import time from typing import Any @@ -44,6 +45,45 @@ DEFAULT_MAX_RETRIES = 3 INITIAL_BACKOFF = 1.0 # seconds +# BC's "property not found" message. We extract the offending field and the +# entity set from the URL so the hint can name the exact `bcli endpoint +# fields ` command to run. This pattern is BC-stable: same wording +# across v2.0 standard and custom v1.x APIs. +_PROPERTY_NOT_FOUND_RE = re.compile( + r"Could not find a property named '([^']+)' on type 'Microsoft\.NAV\.\w+'", +) +_ENTITY_FROM_URL_RE = re.compile(r"/companies\([^)]+\)/([A-Za-z0-9_]+)") + + +def _hint_for_bc_error(status: int, bc_message: str | None, url: str) -> str | None: + """Compute a one-line follow-up command hint for known BC error patterns. + + Returns ``None`` when the error doesn't match a known pattern; the caller + should leave the message unchanged in that case. The goal is to teach an + AI agent (or human) the *next bcli command* to run at the moment of + failure, not to explain the error itself. + """ + if not bc_message: + return None + + if status == 400: + m = _PROPERTY_NOT_FOUND_RE.search(bc_message) + if m: + entity_match = _ENTITY_FROM_URL_RE.search(url) + if entity_match: + entity = entity_match.group(1) + return ( + f"Run 'bcli endpoint fields {entity}' to discover the actual " + f"field names on this endpoint. Don't guess them — BC custom " + f"APIs don't always follow obvious naming." + ) + return ( + "Run 'bcli endpoint fields ' to discover the actual " + "field names on this endpoint." + ) + + return None + class BCTransport: """HTTP transport with auth injection, retry, and BC error parsing.""" @@ -176,10 +216,12 @@ async def _request( if status == 429: kwargs["retry_after"] = _get_retry_after(response) - raise error_cls( - f"HTTP {status} {response.reason_phrase}: {method} {url}", - **kwargs, - ) + message = f"HTTP {status} {response.reason_phrase}: {method} {url}" + hint = _hint_for_bc_error(status, bc_message, url) + if hint: + message = f"{message}\n Hint: {hint}" + + raise error_cls(message, **kwargs) except ( httpx.ConnectError, diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index 56bf0b1..059fd31 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -40,7 +40,16 @@ def _enable_debug_logging() -> None: app = typer.Typer( name="bcli", - help="CLI for Microsoft Dynamics 365 Business Central APIs", + help=( + "CLI for Microsoft Dynamics 365 Business Central APIs.\n\n" + "[bold]Discovery (handy for AI agents driving bcli):[/bold]\n" + " bcli endpoint search fuzzy-find an endpoint\n" + " bcli endpoint info -f json structured metadata\n" + " bcli endpoint fields discover real field names " + "(don't guess)\n" + " --profile alone is enough — environment, company, and\n" + " client_id resolve from the profile. Pass -e only to [italic]override[/italic]." + ), no_args_is_help=True, rich_markup_mode="rich", ) diff --git a/src/bcli_cli/output/_formatters.py b/src/bcli_cli/output/_formatters.py index 4581ac8..5906599 100644 --- a/src/bcli_cli/output/_formatters.py +++ b/src/bcli_cli/output/_formatters.py @@ -12,6 +12,18 @@ from rich.console import Console from rich.table import Table +# Belt-and-suspenders: if we *do* end up rendering rich's box-drawing chars +# on Windows, make sure they go out as UTF-8 bytes rather than the legacy +# CP1252-coded mojibake (`�`). Python ≥ 3.7 supports reconfigure(); bcli +# requires 3.10. No-op on POSIX. +if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + # Detached or already-wrapped streams — leave them alone. + pass + console = Console() stderr_console = Console(stderr=True) @@ -22,6 +34,12 @@ def detect_default_format() -> str: AI coding agents (Claude Code, etc.) and piped/redirected stdout get a markdown table — readable, parseable, no ANSI escapes or box-drawing characters. Interactive TTYs get the rich table. + + On Windows, classic PowerShell pretends to be a TTY even when its + stdout is being captured by a parent process (e.g. an AI agent's + Bash tool), and renders rich's UTF-8 box-drawing as `�` mojibake on + the default codepage. So we treat anything-but-Windows-Terminal as + non-table by default. Set ``BCLI_FORMAT=table`` to force it. """ if os.environ.get("BCLI_FORMAT"): return os.environ["BCLI_FORMAT"] @@ -30,6 +48,10 @@ def detect_default_format() -> str: return "markdown" if not sys.stdout.isatty(): return "markdown" + if sys.platform == "win32" and not os.environ.get("WT_SESSION"): + # Legacy console host (conhost.exe) — table rendering is unreliable. + # Windows Terminal sets WT_SESSION; keep tables there. + return "markdown" return "table" @@ -57,8 +79,17 @@ def _format_table(records: list[dict[str, Any]]) -> None: display_cols = columns[:max_cols] table = Table(show_header=True, header_style="bold", show_lines=False) - for col in display_cols: - table.add_column(col, overflow="ellipsis", max_width=40) + # The first column is usually the identifier the caller actually needs + # to read in full (`entity_set_name`, `no`, `systemId`, etc.). Letting + # rich clip that to 40 chars + ellipsis turns useful output into + # garbage on narrow terminals — the case that prompted this carve-out. + # Subsequent columns stay capped so wide values don't push everything + # off-screen. + for i, col in enumerate(display_cols): + if i == 0: + table.add_column(col, no_wrap=True) + else: + table.add_column(col, overflow="ellipsis", max_width=40) if truncated: table.add_column("...", style="dim") diff --git a/tests/test_cli/test_output_format.py b/tests/test_cli/test_output_format.py new file mode 100644 index 0000000..bdc49c1 --- /dev/null +++ b/tests/test_cli/test_output_format.py @@ -0,0 +1,72 @@ +"""Tests for format auto-detection — agents, non-TTYs, Windows fallback.""" + +from __future__ import annotations + +import pytest + +from bcli_cli.output._formatters import detect_default_format + + +@pytest.fixture(autouse=True) +def _reset_env(monkeypatch): + """Clear all the env vars detect_default_format checks so each test + starts from a known baseline.""" + for key in ("BCLI_FORMAT", "CLAUDECODE", "BCLI_AGENT", "WT_SESSION"): + monkeypatch.delenv(key, raising=False) + + +def _force_tty(monkeypatch, is_tty: bool) -> None: + monkeypatch.setattr("sys.stdout.isatty", lambda: is_tty) + + +class TestDefaultFormat: + def test_explicit_bcli_format_env_wins(self, monkeypatch): + monkeypatch.setenv("BCLI_FORMAT", "csv") + _force_tty(monkeypatch, True) + assert detect_default_format() == "csv" + + def test_claude_code_gets_markdown(self, monkeypatch): + monkeypatch.setenv("CLAUDECODE", "1") + _force_tty(monkeypatch, True) + assert detect_default_format() == "markdown" + + def test_generic_agent_gets_markdown(self, monkeypatch): + monkeypatch.setenv("BCLI_AGENT", "1") + _force_tty(monkeypatch, True) + assert detect_default_format() == "markdown" + + def test_non_tty_gets_markdown(self, monkeypatch): + _force_tty(monkeypatch, False) + assert detect_default_format() == "markdown" + + def test_posix_tty_gets_table(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + _force_tty(monkeypatch, True) + assert detect_default_format() == "table" + + +class TestWindowsLegacyConsole: + """Classic PowerShell / conhost.exe pretends to be a TTY even when + rich's box-drawing renders as `�` mojibake. We default to markdown + there and only switch to table when we can prove the terminal can + handle it (Windows Terminal sets WT_SESSION).""" + + def test_windows_legacy_console_uses_markdown(self, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + _force_tty(monkeypatch, True) + # No WT_SESSION = classic console + assert detect_default_format() == "markdown" + + def test_windows_terminal_keeps_table(self, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("WT_SESSION", "deadbeef-1234") + _force_tty(monkeypatch, True) + assert detect_default_format() == "table" + + def test_explicit_table_format_overrides_windows_safety_net(self, monkeypatch): + # Power user knows what they're doing: BCLI_FORMAT=table on classic + # PowerShell still gives them table. They can fix their codepage. + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("BCLI_FORMAT", "table") + _force_tty(monkeypatch, True) + assert detect_default_format() == "table" diff --git a/tests/test_client/test_transport.py b/tests/test_client/test_transport.py index 0a6b26d..20a37b3 100644 --- a/tests/test_client/test_transport.py +++ b/tests/test_client/test_transport.py @@ -12,6 +12,7 @@ from bcli.client._transport import ( BCTransport, _get_retry_after, + _hint_for_bc_error, _parse_bc_error, ) from bcli.errors import ( @@ -263,3 +264,65 @@ async def test_log_context_passed_through(self, httpx_mock, caplog): record = json.loads(caplog.records[0].message) assert record["endpoint_tier"] == "standard" assert record["environment"] == "Sandbox" + + +class TestBCErrorHints: + """Hints appended to error messages so AI agents (and humans) learn the + next bcli command to run at the moment of failure.""" + + def test_property_not_found_emits_endpoint_fields_hint(self): + url = ( + "https://api.businesscentral.dynamics.com/v2.0/Production/api/" + "beautech/technical/v1.5/companies(abc123)/preservationStatuses" + ) + bc_message = ( + "Could not find a property named 'postingDate' on type " + "'Microsoft.NAV.preservationStatus'." + ) + hint = _hint_for_bc_error(400, bc_message, url) + assert hint is not None + assert "bcli endpoint fields preservationStatuses" in hint + + def test_property_not_found_with_unparseable_url_falls_back(self): + bc_message = ( + "Could not find a property named 'foo' on type 'Microsoft.NAV.bar'." + ) + hint = _hint_for_bc_error(400, bc_message, "https://example.invalid/foo") + assert hint is not None + assert "bcli endpoint fields " in hint + + def test_unknown_400_message_returns_no_hint(self): + # Don't fabricate hints for errors we don't recognise. + assert _hint_for_bc_error(400, "Some other 400 reason", "https://x/") is None + + def test_non_400_status_returns_no_hint(self): + # Forbidden, server errors, etc. don't get auto-hints (yet). + assert _hint_for_bc_error(403, "Forbidden", "https://x/") is None + + def test_empty_bc_message_returns_no_hint(self): + assert _hint_for_bc_error(400, None, "https://x/") is None + assert _hint_for_bc_error(400, "", "https://x/") is None + + async def test_400_with_property_not_found_includes_hint_in_raised_error( + self, httpx_mock, + ): + httpx_mock.add_response( + status_code=400, + json={ + "error": { + "message": ( + "Could not find a property named 'postingDate' on type " + "'Microsoft.NAV.preservationStatus'." + ) + } + }, + ) + transport = BCTransport(FakeAuth(), timeout=5, max_retries=0) + + with pytest.raises(ValidationError) as exc: + await transport.get( + "https://api.businesscentral.dynamics.com/v2.0/Production/api/" + "beautech/technical/v1.5/companies(abc)/preservationStatuses" + ) + + assert "bcli endpoint fields preservationStatuses" in str(exc.value)