Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <fuzzy-term>
bcli --profile my-profile endpoint info <name> -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 "<guess> 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 <name>
```

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 <part-of-name>
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 <endpoint>` 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 <name>
│ │
│ Yes
│ │
└─ bcli --profile <p> get <name> --filter "<field> eq '<value>'" -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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions src/bcli/client/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pattern>' 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."
)

Expand Down
50 changes: 46 additions & 4 deletions src/bcli/client/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
import re
import time
from typing import Any

Expand Down Expand Up @@ -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 <name>` 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 <endpoint>' to discover the actual "
"field names on this endpoint."
)

return None


class BCTransport:
"""HTTP transport with auth injection, retry, and BC error parsing."""
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/bcli_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pattern> fuzzy-find an endpoint\n"
" bcli endpoint info <name> -f json structured metadata\n"
" bcli endpoint fields <name> discover real field names "
"(don't guess)\n"
" --profile <name> 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",
)
Expand Down
35 changes: 33 additions & 2 deletions src/bcli_cli/output/_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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"]
Expand All @@ -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"


Expand Down Expand Up @@ -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")

Expand Down
Loading
Loading