From 5de89e1227245e76c0bc85e425ee387a3bdb897b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 22:50:07 +0000 Subject: [PATCH 1/5] chore: remove company-specific references ahead of OSS launch - Drop internal IMPLEMENTATION-SUMMARY.md worklog - Replace Beautech mentions in docs, docstrings, and tests with neutral wording - Anonymize a real production record and real-looking serials, tail numbers, and customer names in test fixtures and examples Co-authored-by: Igor --- AGENTS.md | 4 +- IMPLEMENTATION-SUMMARY.md | 247 ------------------ docs/extraction.md | 6 +- docs/mcp-server.md | 2 +- docs/saved-queries.md | 4 +- finance-2026.05.07-1.tar.gz | Bin 0 -> 615 bytes src/bcli/odata/_escape.py | 6 +- src/bcli/packs/_ledger.py | 2 +- src/bcli/packs/_registry.py | 2 +- src/bcli_cli/commands/skill_init_cmd.py | 10 +- src/bcli_cli/output/_formatters.py | 2 +- tests/test_cli/test_query_cmd.py | 22 +- tests/test_cli/test_records_format.py | 50 ++-- tests/test_client/test_transport.py | 4 +- tests/test_describe/test_describe_cmd.py | 4 +- tests/test_odata/test_escape.py | 4 +- tests/test_odata/test_filter_fields.py | 10 +- tests/test_queries/test_expand.py | 8 +- tests/test_queries/test_params.py | 6 +- .../test_skill_init/test_skill_init_wizard.py | 11 +- 20 files changed, 78 insertions(+), 326 deletions(-) delete mode 100644 IMPLEMENTATION-SUMMARY.md create mode 100644 finance-2026.05.07-1.tar.gz diff --git a/AGENTS.md b/AGENTS.md index b76d018..2e0c178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ redundant flags that the profile + registry already supply. ```bash # ❌ Don't write this: -bcli -c LLC get fixedAssets --publisher beautech --group finance --version v1.5 --all -f json +bcli -c LLC get fixedAssets --publisher contoso --group finance --version v1.5 --all -f json # ✅ Write this: bcli -c LLC get fixedAssets @@ -55,7 +55,7 @@ bcli -c LLC get fixedAssets Why each flag was wrong: -- `--publisher beautech --group finance --version v1.5` — the +- `--publisher contoso --group finance --version v1.5` — the registry resolves these automatically. Only pass them if the endpoint isn't in the registry (and even then, prefer importing it). - `--all` — pulls **every** page. Most asks need `--top 5` or no diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md deleted file mode 100644 index f39a310..0000000 --- a/IMPLEMENTATION-SUMMARY.md +++ /dev/null @@ -1,247 +0,0 @@ -# Implementation summary — Parts 0-3 (pack / ask / site plan) - -Worktree: `/Users/igor/Projects/2_Areas/D_Internal_CLI_Tooling/bcli/.claude/worktrees/agent-a5724d8b39c9ef6d9` -Branch: `worktree-agent-a5724d8b39c9ef6d9` -Plan: `/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md` - -Total commits on this branch: **11** (one per logical sub-unit). - -## Part 0 — `bcli.context` infrastructure (R1, R4, R5, R6) - -Status: **shipped, green** — 40 tests pass, ruff clean. - -Files: -- `src/bcli/context/__init__.py` — public surface -- `src/bcli/context/_protocol.py` — pre-existing skeleton, refined - with `to_prompt_text()` Markdown renderer (no other changes — - protocol was already R4-aligned) -- `src/bcli/context/_redact.py` — three-layer redaction composing - `bcli/audit/_redact.py` (keys) + `bcli/telemetry/events.py` regex - (patterns) + new URL/GUID/attachment scrub. Five stable - `rule_id` constants. -- `src/bcli/context/_last_error.py` — captures `BCLIError` exits - to `~/.config/bcli/last-error.json`. No tracebacks by default; - `--debug` runs also produce `last-error-debug.json` at mode 0600. -- `src/bcli/context/_http_tail.py` — `RotatingFileHandler` on the - `bcli.http` logger; opt-in via `[context] tail = true`. -- `src/bcli/context/_bundle.py` — `build_bundle()` pure function; - token-budgeted priority truncation (question > last_error > - profile > http > describe > attachments). -- `src/bcli/config/_model.py` — `ContextConfig` added. -- `src/bcli_cli/app.py` — central error handler now calls - `capture_last_error`; root callback bootstraps the http-tail - handler when configured. - -Tests: 40 in `tests/test_context/` covering dataclass round-trip, -3-layer redaction (adversarial nested JSON, URL-encoded tokens, -base64 JWTs), audit-trail completeness, last-error capture w/o -tracebacks, http-tail rotation + size cap, bundle composition -with all sources, no-context path. - -Commits: -- `f3f448f feat(context): bcli.context — typed ContextBundle + 3-layer redaction` -- `fa0ebcb feat(context): wire last-error capture + http-tail bootstrap into CLI` -- `2eb26a3 test(context): cover dataclass round-trip, 3-layer redaction, audit trail` -- `e3b1714 docs(changelog): note Part 0 (bcli.context infrastructure)` - -## Part 1 — `bcli pack` (R2, R3, R7, R8) - -Status: **shipped, green** — 19 tests pass, ruff clean. Both -built-in packs install end-to-end against a tmp config dir. - -Files: -- `src/bcli/packs/_protocol.py` — frozen dataclasses (Pack, - PackManifest, PackContents, AgentFragment, PackQuery, PackBatch, - PackRegistryPreset). `targets: [agents] | [claude] | [agents, claude]` - per fragment (R3 default `[agents]`). -- `src/bcli/packs/_loader.py` — manifest + content loader with - schema validation. -- `src/bcli/packs/_registry.py` — discovery: built-in (`packs/`) - + entry-point group `bcli.packs` + local path. Wheel-install - fallback via `bcli/packs/_builtin/`. -- `src/bcli/packs/_ledger.py` — JSON ledger at - `~/.config/bcli/packs//.json` (R2). Frozen - dataclasses; atomic write. -- `src/bcli/packs/_installer.py` — `plan_install` + `execute_install` - + `uninstall_pack`. Marker blocks with content_hash; idempotent - re-install; provenance-injected registry presets; conflict - detection refuses unless `--replace-owned --accept-conflicts` (R7). -- `src/bcli_cli/commands/pack_cmd.py` — `bcli pack list / info / - install / uninstall`. Dry-run; per-install confirm; pack - recommendations surfaced as hints, never auto-enabled (R8). -- `packs/starter-generic/` — 6 queries, 2 batches, 3 fragments; - standard v2.0 endpoints only. -- `packs/cronus-demo/` — Microsoft CRONUS month-end demo (lifted - from `examples/`). -- `pyproject.toml` — `[tool.hatch.build.targets.wheel.force-include]` - maps `packs/` → `bcli/packs/_builtin` so the wheel ships the - built-ins. - -Tests: 19 in `tests/test_packs/` covering manifest validation, -fragment-targets routing (agents vs claude vs both), install -round-trips, idempotency, conflict detection, uninstall, -discovery, broken-pack tolerance, and end-to-end install of both -built-in packs. - -Smoke-test (manually run): -- `bcli pack list` shows both built-in packs. -- `bcli pack info starter-generic` shows manifest + content - counts + "not installed on profile X". -- `bcli pack install starter-generic --dry-run --target /tmp/X` - prints the full plan (3 fragments + 3 marker blocks + 6 queries - + 2 batches) without touching disk. - -Commits: -- `e3eb1eb feat(packs): bcli.packs SDK — Pack/Manifest/Ledger + installer (R2, R3, R7, R8)` -- `c61627b feat(packs): bcli pack list / info / install / uninstall CLI` -- `a6a8c46 feat(packs): ship starter-generic + cronus-demo built-in packs` -- `ebc0544 test(packs): 19 tests + pyproject pack wheel layout + changelog` - -## Part 2 — `bcli ask` - -Status: **shipped, green** — 16 tests pass, ruff clean. CLI -smoke-test (`bcli ask --dry-run --no-context "test"`) prints the -redacted bundle without making a network call. - -Files: -- `src/bcli/ask/_protocol.py` — `AskBackend` Protocol + NullAsker - (mirror of `bcli/extract/_protocol.py`). -- `src/bcli/ask/_factory.py` — `get_asker` dispatch with - `_BUILTIN_BACKENDS` + `module:Class` fallback + Null fallback - with one-shot warning (mirror of `bcli/extract/_factory.py`). -- `src/bcli/ask/_claude.py` — Anthropic backend - (`messages.create`, bundle as Markdown user-turn). -- `src/bcli/ask/_openai.py` — OpenAI Responses API backend. -- `src/bcli/ask/_providers.py` — `bcli.ask.context_providers` - entry-point group (R8). Opt-in via `[ask] context_providers`. -- `src/bcli_cli/commands/ask_cmd.py` — `bcli ask ""` with - `--no-context`, `--attach`, `--backend`, `--dry-run`, - `--include-bodies`, `--include-debug`, `--max-tokens`. -- `src/bcli/config/_model.py` — `AskConfig` added. -- `pyproject.toml` — new extras `[ask-claude]`, `[ask-openai]`, - meta-extra `[ask]`; `[dev]` also pulls in `[ask]`. - -Tests: 16 in `tests/test_ask/` covering factory dispatch (all -fallback paths), `--dry-run` rendering + attachment redaction + -no-network guarantee, and the R8 context-provider entry-point -group (opt-in execution, failure isolation). - -Commit: -- `30e7545 feat(ask): bcli ask oracle — Claude/OpenAI backends + dry-run + R8 providers` - -## Part 3 — `bcli-site/` v0 - -Status: **shipped** — files only; JSON/YAML parses cleanly. No -`pnpm install` was run (no guaranteed network in this sandbox). - -Files: -- `bcli-site/package.json`, `astro.config.mjs`, `tsconfig.json`, - `tailwind.config.mjs` — Astro 4 + Tailwind 3 stack. -- `bcli-site/src/pages/index.astro` — single page: hero + - install + 3 example commands (pack install, saved query, ask) - + features grid + footer with GitHub link. -- `bcli-site/src/components/Hero.astro`, `CodeBlock.astro`, - `styles/global.css`. -- `bcli-site/public/og.png.placeholder` — TODO note for a - hand-crafted OG card. -- `bcli-site/README.md` — pnpm dev / pnpm build instructions + - Vercel deploy note. -- `.github/workflows/site.yml` — Astro build on changes under - `bcli-site/**`. Vercel deploy step is wired but commented out - until secrets exist; secrets use `env:` blocks per GitHub's - injection guidance. -- `.gitignore` — adds `bcli-site/node_modules`, `dist`, `.astro`, - and lockfiles. - -Content compliance with R9: describes shipped features (packs, -ask, MCP server, describe / discovery-first). Does NOT mention -the deferred `bcli agent` mode anywhere on the page. - -Commit: -- `44a13f9 feat(site): bcli-site v0 — Astro + Tailwind landing scaffold` - -## Final validation snapshot - -``` -$ .venv/bin/python -m pytest tests/test_context tests/test_packs tests/test_ask -v -75 passed in 0.36s - -$ .venv/bin/python -m pytest tests/ -906 passed, 5 skipped (full suite — no regressions) - -$ .venv/bin/python -m ruff check src/ -All checks passed! - -$ bcli pack list # shows both built-in packs -$ bcli pack install starter-generic --dry-run # 3 fragments + 6 queries + 2 batches -$ bcli ask --dry-run --no-context "test" # redacted bundle, no network -``` - -## Out of scope / STUCK - -No STUCK files were written — every Part landed within scope. One -late finding from the advisor reconcile pass landed two bug fixes -+ regression tests before "done": - -- **`bcli ask --no-context` was leaking last-error** because - `build_bundle` reads `last-error.json` from disk when no record - is passed. Added `skip_last_error=True` parameter to the builder - and threaded it through the CLI. Regression test - `test_no_context_suppresses_existing_last_error` writes a real - last-error file, then asserts the phrase appears in the default - bundle but NOT in the `--no-context` bundle. -- **`bcli ask --include-debug` was wired but inert.** The CLI now - reads `last-error-debug.json` (mode 0600 sidecar) when the flag - is set. Regression test `test_include_debug_reads_traceback_sidecar` - asserts "Traceback" absent by default + present with the flag. - -Two follow-up items the next session should pick up: - -1. **`bcli describe` excerpt wiring in `bcli ask`.** The `ask` - command's `--no-context` flag is honoured, but the *default* - path does not yet subprocess `bcli describe` into the bundle. - Wiring is straightforward (`subprocess.run(["bcli", "describe", - "--format", "json"], …)`), gated on `cfg.include_describe`. Left - as a TODO so the first PR stays focused on the LLM-call surface. - -2. **Idempotent pack uninstall on missing-marker** — the installer - tolerates a missing marker block (warns) but does not force a - re-walk of the AGENTS.md/CLAUDE.md content to verify the rest of - the block hasn't been re-edited. Plan reference: R2 lists this - as the `--force` opt-in path; UX wiring is deferred. - -## Wheel build smoke-test - -`python -m build --wheel` builds cleanly. Verified that the -hatch `force-include` mapping ships both built-in packs: - -``` -$ unzip -l dist/bc_cli-0.4.0-py3-none-any.whl | grep _builtin -bcli/packs/_builtin/cronus-demo/pack.yaml -bcli/packs/_builtin/cronus-demo/batches/month-end-cronus.yaml -bcli/packs/_builtin/cronus-demo/fragments/... -bcli/packs/_builtin/starter-generic/pack.yaml -bcli/packs/_builtin/starter-generic/batches/... -bcli/packs/_builtin/starter-generic/fragments/... -bcli/packs/_builtin/starter-generic/queries/... -``` - -`builtin_packs_dir()` looks at both the repo-root `packs/` (editable -install) and the wheel-shipped `bcli/packs/_builtin/`, so `bcli pack -list` works for users installed via `pip install bc-cli`. - -## Recommended follow-up - -**Beautech companion plan — see Part 1B in -`/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md`**. - -The OSS plan above ships mechanism + two generic packs. The -companion plan turns `bcli-beautech-bootstrap`'s existing assets -(`finance.queries.yaml`, `technical.queries.yaml`, -`workflows/*.batch.yaml`, etc.) into three downstream packs -(`beautech-finance`, `beautech-technical`, -`beautech-customer-360`) registering via the -`bcli.packs` entry-point group, plus a Beautech `bcli.ask -context_provider` for the aviation glossary. Touches only the -private bootstrap repo; the OSS pack/ask machinery is the -extension surface it consumes. diff --git a/docs/extraction.md b/docs/extraction.md index 4d77e61..ec5c47f 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -214,9 +214,9 @@ warning — extraction never crashes the CLI on a config mistake. - Both built-ins accept the same schema. Switching is a one-line config change; you can iterate a schema cheaply on one provider and promote with the other. -- Aviation/regulated data: pick the provider with the residency / - compliance posture your org accepts. Neither built-in routes through - Beautech infrastructure — your API key, your traffic. +- Regulated data: pick the provider with the residency / compliance + posture your org accepts. Neither built-in routes through any + bcli-operated infrastructure — your API key, your traffic. - Cost: at time of writing, both providers price PDF input in the same ballpark for short documents. Long tabular reports tend to favor whichever provider has the cheaper input-token rate. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index b307a17..477abc9 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -181,7 +181,7 @@ often cheaper because Claude renders compact markdown tables directly without the JSON serialization overhead. Use the right tool for the shape of the question. -## Future: a Beautech-specific MCP (separate package) +## Domain-specific MCPs (separate packages) The OSS `bcli-mcp` is intentionally generic. Domain-specific MCPs that combine BC OData with cross-system data (legal docs, market intel, fleet analytics) diff --git a/docs/saved-queries.md b/docs/saved-queries.md index 09ab02d..93f4203 100644 --- a/docs/saved-queries.md +++ b/docs/saved-queries.md @@ -107,7 +107,7 @@ queries: airline: required: false type: string - enum: ["AIRNORTH", "QANTAS", "VIRGIN"] + enum: ["ACMEAIR", "CONTOSO", "FABRIKAM"] filter: "engineSerialNumber eq '${{ params.esn }}'" orderby: "asOfDate desc" top: "${{ params.limit }}" @@ -117,7 +117,7 @@ queries: When a string-typed param is interpolated into the `filter:` field, `bcli` applies OData v4 single-quote escaping (`'` → `''`) so a value like -`193208' or 1 eq 1--` cannot break out of the surrounding string literal. +`100001' or 1 eq 1--` cannot break out of the surrounding string literal. This escape is scoped to the filter context — `select`, `orderby`, `top`, `skip`, `all`, and `endpoint` keep raw values, since they don't sit inside OData string literals. diff --git a/finance-2026.05.07-1.tar.gz b/finance-2026.05.07-1.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..40a83c2f8b030bf8006ea5a82db904405690a676 GIT binary patch literal 615 zcmV-t0+{_DiwFR9K)h-K|LvAdZ`&{ohI8((2z+h=Ss#{^`)!z*&IqzrHs|A=}I2jvWb#8ZfaEv zUX^hlZXQ<1TjuxAarHX&zZmKB`p*c*`u~FGzbnZe2MC)XF8Av-gl>pFM7CZ9_7pYn0>zAC&>F?`lUHK7neA` zhwcF5yO8I4QQel_#$-6e@qTu(WgBBZt5@w@2D6BVF;2r(A9>Y9p6e}{CFkMPEn%0* zJO=(S^=%oh*i9d&kgL0gJ8Vl31VIo4K@bE%5ClOG1VIo4K@j9G`~>F%1f2jV000Is BH4Fd% literal 0 HcmV?d00001 diff --git a/src/bcli/odata/_escape.py b/src/bcli/odata/_escape.py index 361790c..6320b85 100644 --- a/src/bcli/odata/_escape.py +++ b/src/bcli/odata/_escape.py @@ -2,7 +2,7 @@ Saved queries and other places that interpolate user-supplied values into a ``$filter`` string need to escape single quotes so a value like -``193208' or 1 eq 1--`` cannot break out of the string literal it's pasted +``100001' or 1 eq 1--`` cannot break out of the string literal it's pasted into. This module centralises that rule so both the saved-query layer and any future call-sites stay consistent. @@ -26,7 +26,7 @@ def escape_odata_string(value: str) -> str: 'Acme' >>> escape_odata_string("O'Brien") "O''Brien" - >>> escape_odata_string("193208' or 1 eq 1--") - "193208'' or 1 eq 1--" + >>> escape_odata_string("100001' or 1 eq 1--") + "100001'' or 1 eq 1--" """ return value.replace("'", "''") diff --git a/src/bcli/packs/_ledger.py b/src/bcli/packs/_ledger.py index 614e090..0054b9c 100644 --- a/src/bcli/packs/_ledger.py +++ b/src/bcli/packs/_ledger.py @@ -30,7 +30,7 @@ "registry_endpoints": [ {"name": "myEntity", "rendered_hash": "sha256:..."} ], - "recommended_context_providers": ["beautech"] + "recommended_context_providers": ["my-org"] } """ diff --git a/src/bcli/packs/_registry.py b/src/bcli/packs/_registry.py index e8165c1..ed7bef0 100644 --- a/src/bcli/packs/_registry.py +++ b/src/bcli/packs/_registry.py @@ -129,7 +129,7 @@ def discover_all(*, builtin_root: Path | None = None) -> dict[str, Pack]: On name collision the later source wins, so a third-party pack can override a built-in by registering the same name (rare but - sometimes desirable for Beautech-style overlays). + sometimes desirable for organization-specific overlays). """ out: dict[str, Pack] = {} for p in discover_builtin_packs(builtin_root): diff --git a/src/bcli_cli/commands/skill_init_cmd.py b/src/bcli_cli/commands/skill_init_cmd.py index 0a32411..9276630 100644 --- a/src/bcli_cli/commands/skill_init_cmd.py +++ b/src/bcli_cli/commands/skill_init_cmd.py @@ -2,9 +2,9 @@ Per-user setup wizard that consumes ``bcli describe`` and writes a right-sized Claude Code skill bundle plus optional new saved-query -entries. **Mechanism only** — the OSS package ships no Beautech-specific -role templates. Third-party packages (``bcli-beautech-bootstrap``) plug -in via the ``bcli.skill_init.role_templates`` entry-point group. +entries. **Mechanism only** — the OSS package ships no organization-specific +role templates. Third-party packages plug in via the +``bcli.skill_init.role_templates`` entry-point group. Hard guarantees this module enforces: @@ -255,7 +255,7 @@ def _surface_queries_for_interests( ) -> tuple[SurfacedSlashCommand, ...]: """Pick the saved queries that fuzzy-match the user's free-text top-3. - No role-keyed logic — that would smuggle Beautech-specific + No role-keyed logic — that would smuggle organization-specific affinities into the OSS package. We tokenise the top-3 phrase on whitespace + commas, then take any query whose ``description``, ``name``, or ``endpoint`` contains the token (or has a SequenceMatcher @@ -305,7 +305,7 @@ def _default_role_template_proposer( ) -> list[ProposedQuery]: """OSS default: no proposals. - Beautech (or any third party) plugs in via the + Third parties plug in via the ``bcli.skill_init.role_templates`` entry-point group — each provider is a callable with the same signature returning a list of :class:`ProposedQuery`. The OSS mechanism stays opinion-free; role diff --git a/src/bcli_cli/output/_formatters.py b/src/bcli_cli/output/_formatters.py index 7ce4a07..e265913 100644 --- a/src/bcli_cli/output/_formatters.py +++ b/src/bcli_cli/output/_formatters.py @@ -255,7 +255,7 @@ def _format_records(records: list[dict[str, Any]]) -> None: record 1 systemId : b1fc5e63-… - engineSerialNumber : 194108 + engineSerialNumber : 100002 engineType : CF34-8C … diff --git a/tests/test_cli/test_query_cmd.py b/tests/test_cli/test_query_cmd.py index 4972a1d..167033a 100644 --- a/tests/test_cli/test_query_cmd.py +++ b/tests/test_cli/test_query_cmd.py @@ -75,13 +75,13 @@ def test_resolve_params_required_missing_exits(): def test_resolve_params_required_supplied(): declared = {"esn": {"required": True}} - resolved = _resolve_params(declared, ["esn=193208"]) - assert resolved == {"esn": 193208} # YAML coerces digits + resolved = _resolve_params(declared, ["esn=100001"]) + assert resolved == {"esn": 100001} # YAML coerces digits def test_resolve_params_string_value(): - resolved = _resolve_params({"airline": {"required": True}}, ["airline=AIRNORTH"]) - assert resolved == {"airline": "AIRNORTH"} + resolved = _resolve_params({"airline": {"required": True}}, ["airline=ACMEAIR"]) + assert resolved == {"airline": "ACMEAIR"} def test_resolve_params_invalid_format_exits(): @@ -98,8 +98,8 @@ def test_expand_query_resolves_param_references(): "filter": "engineSerialNumber eq '${{ params.esn }}'", "top": 24, } - expanded = _expand_query(spec, {"esn": "193208"}) - assert expanded["filter"] == "engineSerialNumber eq '193208'" + expanded = _expand_query(spec, {"esn": "100001"}) + assert expanded["filter"] == "engineSerialNumber eq '100001'" assert expanded["endpoint"] == "engineUtilizations" assert expanded["top"] == 24 @@ -130,10 +130,10 @@ def test_expand_query_neutralises_injection_in_filter(): "endpoint": "engineUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", } - expanded = _expand_query(spec, {"esn": "193208' or 1 eq 1--"}) + expanded = _expand_query(spec, {"esn": "100001' or 1 eq 1--"}) # The injected quote is doubled, so the literal stays well-formed and the # ``or 1 eq 1--`` ends up inside the string instead of as new operators. - assert expanded["filter"] == "engineSerialNumber eq '193208'' or 1 eq 1--'" + assert expanded["filter"] == "engineSerialNumber eq '100001'' or 1 eq 1--'" assert expanded["filter"].count("'") % 2 == 0 @@ -199,8 +199,8 @@ def test_integer_within_bounds_accepted(self): def test_string_pattern_accepts_match(self): declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} - resolved = _resolve_params(declared, ["airline=AIRNORTH"]) - assert resolved == {"airline": "AIRNORTH"} + resolved = _resolve_params(declared, ["airline=ACMEAIR"]) + assert resolved == {"airline": "ACMEAIR"} def test_string_pattern_rejects_non_match(self): declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} @@ -211,7 +211,7 @@ def test_string_pattern_rejects_injection_attempt(self): """The injection example from the review fails the ESN pattern check.""" declared = {"esn": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} with pytest.raises(typer.Exit): - _resolve_params(declared, ["esn=193208' or 1 eq 1--"]) + _resolve_params(declared, ["esn=100001' or 1 eq 1--"]) def test_enum_accepts_valid(self): declared = {"status": {"required": True, "enum": ["Open", "Posted"]}} diff --git a/tests/test_cli/test_records_format.py b/tests/test_cli/test_records_format.py index 2b1b41d..a1e3cec 100644 --- a/tests/test_cli/test_records_format.py +++ b/tests/test_cli/test_records_format.py @@ -13,36 +13,36 @@ def _wide_record() -> dict: """30-column engine record — the canonical "too wide for table" case.""" return { - "systemId": "b1fc5e63-e150-ef11-bfe3-000d3a7051ef", - "engineSerialNumber": "194108", + "systemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "engineSerialNumber": "100002", "engineType": "CF34-8C", "engineModel": "CF34-8C5", "thrustRating": "8C5", - "initialDate": "2024-05-31", - "lastOperationDate": "2025-02-04", - "initialEngineTsn": "16523.96", - "intialEngineCsn": "11440", - "fc": "135", - "fh": "252.12", + "initialDate": "2024-01-01", + "lastOperationDate": "2025-01-01", + "initialEngineTsn": "10000.00", + "intialEngineCsn": "8000", + "fc": "100", + "fh": "200.00", "lastInvoiceDate": "2026-01-31", - "currentTsn": "17645.24", - "currentCsn": "12236", - "limiter": "12753", - "tspr": "17645.24", - "cspr": "12236", + "currentTsn": "10200.00", + "currentCsn": "8100", + "limiter": "9000", + "tspr": "10200.00", + "cspr": "8100", "tslsv": "0", "cslsv": "0", - "aprCycles": "11", + "aprCycles": "10", "qec": "Neutral", - "ittMargin": "35", - "ittMarginNotes": "May-2025 ECM", + "ittMargin": "30", + "ittMarginNotes": "Example ECM note", "serviceable": "Yes", - "notes": "Sold to United on Mar 18, 2026", + "notes": "Example note", "lessee": "", "operator": "", - "engineStatus": "Sold", - "location": "GO JET", - "systemModifiedAt": "2026-04-06T14:19:25.347Z", + "engineStatus": "Available", + "location": "Example Location", + "systemModifiedAt": "2026-01-01T00:00:00.000Z", } @@ -68,7 +68,7 @@ def test_env_kill_switch(self, monkeypatch): def test_format_output_promotes_table_to_records_for_wide_single(self, capsys): format_output([_wide_record()], fmt="table") out = capsys.readouterr().out - assert "engineSerialNumber : 194108" in out + assert "engineSerialNumber : 100002" in out assert "engineType : CF34-8C" in out def test_format_output_promotes_markdown_to_records_for_wide_single(self, capsys): @@ -76,12 +76,12 @@ def test_format_output_promotes_markdown_to_records_for_wide_single(self, capsys out = capsys.readouterr().out # Markdown table never appears — the auto-fallback fires first. assert "| systemId" not in out - assert "engineSerialNumber : 194108" in out + assert "engineSerialNumber : 100002" in out def test_explicit_records_format_works(self, capsys): format_output([_wide_record()], fmt="records") out = capsys.readouterr().out - assert "engineSerialNumber : 194108" in out + assert "engineSerialNumber : 100002" in out def test_records_alias_r_works(self, capsys): format_output([{"a": "1", "b": "2"}], fmt="r") @@ -94,7 +94,7 @@ def test_csv_format_is_not_promoted(self, capsys): out = capsys.readouterr().out # CSV header line ends with a comma-separated set, not "name : value". assert "engineSerialNumber" in out - assert "engineSerialNumber : 194108" not in out + assert "engineSerialNumber : 100002" not in out def test_explicit_format_disables_auto_promote(self, capsys): """`-f markdown` is a contract; honor it even on a wide single row.""" @@ -102,7 +102,7 @@ def test_explicit_format_disables_auto_promote(self, capsys): out = capsys.readouterr().out # Markdown header pipe present, vertical view absent. assert "| systemId" in out - assert "engineSerialNumber : 194108" not in out + assert "engineSerialNumber : 100002" not in out class TestRecordsRendering: diff --git a/tests/test_client/test_transport.py b/tests/test_client/test_transport.py index 20a37b3..4c2b788 100644 --- a/tests/test_client/test_transport.py +++ b/tests/test_client/test_transport.py @@ -273,7 +273,7 @@ class TestBCErrorHints: 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" + "contoso/technical/v1.5/companies(abc123)/preservationStatuses" ) bc_message = ( "Could not find a property named 'postingDate' on type " @@ -322,7 +322,7 @@ async def test_400_with_property_not_found_includes_hint_in_raised_error( 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" + "contoso/technical/v1.5/companies(abc)/preservationStatuses" ) assert "bcli endpoint fields preservationStatuses" in str(exc.value) diff --git a/tests/test_describe/test_describe_cmd.py b/tests/test_describe/test_describe_cmd.py index f1644fc..affc8aa 100644 --- a/tests/test_describe/test_describe_cmd.py +++ b/tests/test_describe/test_describe_cmd.py @@ -102,7 +102,7 @@ def _write_custom_registry(registries_dir: Path, profile: str = "test") -> Path: "supports": ["GET", "POST", "PATCH"], "key_field": "id", "category": "finance", - "api_publisher": "beautech", + "api_publisher": "contoso", "api_group": "finance", "api_version": "v1.0", "domain": "finance", @@ -115,7 +115,7 @@ def _write_custom_registry(registries_dir: Path, profile: str = "test") -> Path: "supports": ["GET"], "key_field": "id", "category": "aviation", - "api_publisher": "beautech", + "api_publisher": "contoso", "api_group": "aviation", "api_version": "v1.0", "domain": "technical", diff --git a/tests/test_odata/test_escape.py b/tests/test_odata/test_escape.py index 886792b..c599b28 100644 --- a/tests/test_odata/test_escape.py +++ b/tests/test_odata/test_escape.py @@ -23,9 +23,9 @@ def test_multiple_single_quotes(): def test_injection_attempt_neutralised(): """Mimics the saved-query injection example from the security review.""" - raw = "193208' or 1 eq 1--" + raw = "100001' or 1 eq 1--" escaped = escape_odata_string(raw) - assert escaped == "193208'' or 1 eq 1--" + assert escaped == "100001'' or 1 eq 1--" # Quote count is even after escaping, so the literal cannot terminate early. assert escaped.count("'") % 2 == 0 diff --git a/tests/test_odata/test_filter_fields.py b/tests/test_odata/test_filter_fields.py index 15f5dca..7f7616c 100644 --- a/tests/test_odata/test_filter_fields.py +++ b/tests/test_odata/test_filter_fields.py @@ -11,7 +11,7 @@ class TestExtractFieldReferences: def test_simple_eq(self): - refs = extract_field_references("engineSerialNumber eq '193208'") + refs = extract_field_references("engineSerialNumber eq '100001'") assert refs == ["engineSerialNumber"] def test_strips_string_literals(self): @@ -24,7 +24,7 @@ def test_handles_double_quotes(self): def test_compound_filter(self): refs = extract_field_references( - "engineSerialNumber eq '193208' and tailNo eq 'VH-ANO'" + "engineSerialNumber eq '100001' and tailNo eq 'N12345'" ) assert refs == ["engineSerialNumber", "tailNo"] @@ -94,12 +94,12 @@ def test_returns_none_when_known_empty(self): def test_passes_when_all_known(self): assert validate_filter_fields( - "engineSerialNumber eq '193208' and tailNo eq 'VH-ANO'", + "engineSerialNumber eq '100001' and tailNo eq 'N12345'", self.KNOWN, ) is None def test_flags_unknown(self): - result = validate_filter_fields("esn eq '193208'", self.KNOWN) + result = validate_filter_fields("esn eq '100001'", self.KNOWN) assert result is not None msg, unknown = result assert unknown == ["esn"] @@ -109,7 +109,7 @@ def test_flags_unknown(self): assert "engineSerialNumber" in msg or "Did you mean" not in msg def test_flags_typo_with_close_match(self): - result = validate_filter_fields("tailNumber eq 'VH-ANO'", self.KNOWN) + result = validate_filter_fields("tailNumber eq 'N12345'", self.KNOWN) assert result is not None msg, _ = result # 'tailNumber' is close to 'tailNo' via difflib. diff --git a/tests/test_queries/test_expand.py b/tests/test_queries/test_expand.py index f330575..ba02aa4 100644 --- a/tests/test_queries/test_expand.py +++ b/tests/test_queries/test_expand.py @@ -14,8 +14,8 @@ def test_expand_query_resolves_param_references(): "filter": "engineSerialNumber eq '${{ params.esn }}'", "top": 24, } - resolved = expand_query(spec, {"esn": "193208"}) - assert resolved.filter == "engineSerialNumber eq '193208'" + resolved = expand_query(spec, {"esn": "100001"}) + assert resolved.filter == "engineSerialNumber eq '100001'" assert resolved.endpoint == "engineUtilizations" assert resolved.top == 24 @@ -61,8 +61,8 @@ def test_expand_query_neutralises_injection_in_filter(): "endpoint": "engineUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", } - resolved = expand_query(spec, {"esn": "193208' or 1 eq 1--"}) - assert resolved.filter == "engineSerialNumber eq '193208'' or 1 eq 1--'" + resolved = expand_query(spec, {"esn": "100001' or 1 eq 1--"}) + assert resolved.filter == "engineSerialNumber eq '100001'' or 1 eq 1--'" assert resolved.filter.count("'") % 2 == 0 diff --git a/tests/test_queries/test_params.py b/tests/test_queries/test_params.py index ef5979b..c54ad6b 100644 --- a/tests/test_queries/test_params.py +++ b/tests/test_queries/test_params.py @@ -35,7 +35,7 @@ def test_resolve_params_required_missing_raises(): def test_resolve_params_required_supplied(): declared = {"esn": {"required": True}} - assert resolve_params(declared, {"esn": 193208}) == {"esn": 193208} + assert resolve_params(declared, {"esn": 100001}) == {"esn": 100001} def test_resolve_params_none_declared_passes_supplied_through_unvalidated(): @@ -85,7 +85,7 @@ def test_integer_within_bounds_accepted(self): def test_string_pattern_accepts_match(self): declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} - assert resolve_params(declared, {"airline": "AIRNORTH"}) == {"airline": "AIRNORTH"} + assert resolve_params(declared, {"airline": "ACMEAIR"}) == {"airline": "ACMEAIR"} def test_string_pattern_rejects_non_match(self): declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} @@ -95,7 +95,7 @@ def test_string_pattern_rejects_non_match(self): def test_string_pattern_rejects_injection_attempt(self): declared = {"esn": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} with pytest.raises(QueryParamError): - resolve_params(declared, {"esn": "193208' or 1 eq 1--"}) + resolve_params(declared, {"esn": "100001' or 1 eq 1--"}) def test_enum_accepts_valid(self): declared = {"status": {"required": True, "enum": ["Open", "Posted"]}} diff --git a/tests/test_skill_init/test_skill_init_wizard.py b/tests/test_skill_init/test_skill_init_wizard.py index 9f1010d..71c3192 100644 --- a/tests/test_skill_init/test_skill_init_wizard.py +++ b/tests/test_skill_init/test_skill_init_wizard.py @@ -1,9 +1,8 @@ """Tests for ``bcli skill init`` — Phase 7 wizard mechanism. -The wizard is mechanism only. No Beautech-specific role content — the +The wizard is mechanism only. No organization-specific role content — the OSS package emits an *empty* set of new-query proposals by default, and -defers role-aware proposals to entry-point providers (``bcli-beautech- -bootstrap`` ships one such provider). Tests here exercise the +defers role-aware proposals to entry-point providers. Tests here exercise the mechanism: read describe, interview, project existing queries, write provenance-headed files atomically, refuse to write outside the allow- listed dirs. @@ -514,13 +513,13 @@ def _flaky_commit(plan, *args, **kwargs): skill_init_cmd._commit_plan = original_commit -# ─── 10. OSS mechanism emits no Beautech-specific role content ──── +# ─── 10. OSS mechanism emits no organization-specific role content ──── class TestOssMechanismHasNoRoleContent: """Belt-and-braces: the OSS package's default new-query proposer - returns an empty list regardless of role. Beautech (or any third - party) plugs in via the ``bcli.skill_init.role_templates`` entry + returns an empty list regardless of role. Third parties plug in + via the ``bcli.skill_init.role_templates`` entry point group; nothing in this PR ships role-keyed BC entity affinities.""" From 4df2bbf189d5071fef5a93f316cbbf9b3b310207 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 22:56:49 +0000 Subject: [PATCH 2/5] chore: replace organization-specific custom API names in examples engineOverviews, engineUtilizations, engineCards, and preservationStatuses were internal custom endpoint names. Swap them (and the private MCP tool names in docs) for generic equipment-* / inspectionStatuses examples. Co-authored-by: Igor --- AGENTS.md | 2 +- docs/batch-operations.md | 20 +++++++++--------- docs/custom-apis.md | 14 ++++++------ docs/mcp-server.md | 2 +- docs/saved-queries.md | 2 +- docs/sdk-usage.md | 2 +- finance-2026.05.07-1.tar.gz | Bin 615 -> 615 bytes src/bcli/client/_async.py | 4 ++-- tests/fixtures/sample_postman_collection.json | 2 +- tests/test_cli/test_query_cmd.py | 6 +++--- tests/test_client/test_transport.py | 12 +++++------ tests/test_queries/test_expand.py | 6 +++--- tests/test_registry/test_metadata_fields.py | 20 +++++++++--------- tests/test_registry/test_registry.py | 2 +- tests/test_url/test_url_builder.py | 8 +++---- 15 files changed, 51 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e0c178..1a5e14d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ when the user's question demands them. 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`). +obvious-singular (e.g. `inspectionStatuses`, not `inspectionStatus`). The recipe: ```bash diff --git a/docs/batch-operations.md b/docs/batch-operations.md index 8a80103..fc9a662 100644 --- a/docs/batch-operations.md +++ b/docs/batch-operations.md @@ -27,13 +27,13 @@ bcli batch run workflow.yaml --params month-end.yaml name: "Monthly Engine Utilization Upload" steps: - action: get - endpoint: engineOverviews + endpoint: equipmentOverviews params: filter: "engineModel eq 'CF34-10E'" top: 5 - action: post - endpoint: engineUtilizations + endpoint: equipmentUtilizations data: esn: "ESN-123456" period: "2026-03" @@ -41,7 +41,7 @@ steps: flightCycles: 210 - action: post - endpoint: engineUtilizations + endpoint: equipmentUtilizations data: esn: "ESN-789012" period: "2026-03" @@ -49,7 +49,7 @@ steps: flightCycles: 175 - action: patch - endpoint: engineCards + endpoint: equipmentCards id: "a1b2c3d4-..." data: status: "Available" @@ -115,11 +115,11 @@ Output: Batch: Monthly Engine Utilization Upload 3 step(s) - Step 1: GET engineOverviews + Step 1: GET equipmentOverviews Params: {'filter': "engineModel eq 'CF34-10E'", 'top': 5} - Step 2: POST engineUtilizations + Step 2: POST equipmentUtilizations Data: {"esn": "ESN-123456", ...} - Step 3: POST engineUtilizations + Step 3: POST equipmentUtilizations Data: {"esn": "ESN-789012", ...} --dry-run: 3 step(s) would execute. @@ -130,9 +130,9 @@ Batch: Monthly Engine Utilization Upload If a step fails, the error is reported and subsequent steps continue: ``` - Step 1: GET engineOverviews... ✓ 5 record(s) - Step 2: POST engineUtilizations... ✓ created - Step 3: POST engineUtilizations... ✗ HTTP 400: Duplicate record + Step 1: GET equipmentOverviews... ✓ 5 record(s) + Step 2: POST equipmentUtilizations... ✓ created + Step 3: POST equipmentUtilizations... ✗ HTTP 400: Duplicate record ✓ Batch complete: 2/3 steps succeeded ``` diff --git a/docs/custom-apis.md b/docs/custom-apis.md index 939d8eb..23f966b 100644 --- a/docs/custom-apis.md +++ b/docs/custom-apis.md @@ -43,12 +43,12 @@ bcli registry import --from-json ./endpoints.json { "endpoints": [ { - "entity_set_name": "engineOverviews", - "entity_name": "engineOverview", + "entity_set_name": "equipmentOverviews", + "entity_name": "equipmentOverview", "api_publisher": "mycompany", "api_group": "technical", "api_version": "v1.5", - "description": "Engine overview data", + "description": "Equipment overview data", "supports": ["GET"], "key_field": "systemId" } @@ -120,7 +120,7 @@ When you run `bcli get someEntity`: 2. **Standard v2.0** — Falls back to the built-in standard registry. Routes to `/api/v2.0/`. 3. **Not found** — Shows an error with fuzzy search suggestions and hints to import a registry. -This means `bcli get customers` (standard) and `bcli get engineOverviews` (custom) work the same way — you never construct URLs. +This means `bcli get customers` (standard) and `bcli get equipmentOverviews` (custom) work the same way — you never construct URLs. ## Discover Endpoints @@ -141,7 +141,7 @@ bcli endpoint list --category sales bcli endpoint search engine # Full details for one endpoint -bcli endpoint info engineOverviews +bcli endpoint info equipmentOverviews ``` ## Test a Custom Endpoint @@ -149,7 +149,7 @@ bcli endpoint info engineOverviews After importing, verify an endpoint works: ```bash -bcli test endpoint engineOverviews -# ✓ engineOverviews: returned 1 record(s) +bcli test endpoint equipmentOverviews +# ✓ equipmentOverviews: returned 1 record(s) # Fields: systemId, esn, engineModel, status, ... ``` diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 477abc9..aad0875 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -192,7 +192,7 @@ The boundary rule: * **OSS owns** generic BC transport, query construction, registry, OData escaping, auth, retry, telemetry plumbing. * **Private package owns** domain tool composition only — tools like - `engine_lookup`, `lease_amendments_for`, `vendor_analytics` that combine + `equipment_lookup`, `contract_amendments_for`, `vendor_analytics` that combine multiple BC queries with cross-system data into one named operation. * The private MCP is a *consumer* of `bcli` (subprocess or `from bcli import AsyncBCClient`), never a layer that re-implements diff --git a/docs/saved-queries.md b/docs/saved-queries.md index 93f4203..6b2408c 100644 --- a/docs/saved-queries.md +++ b/docs/saved-queries.md @@ -93,7 +93,7 @@ Example with all the validation knobs: queries: utilization-by-esn: description: Monthly utilization records for one ESN - endpoint: engineUtilizations + endpoint: equipmentUtilizations params: esn: required: true diff --git a/docs/sdk-usage.md b/docs/sdk-usage.md index e04dd24..348feac 100644 --- a/docs/sdk-usage.md +++ b/docs/sdk-usage.md @@ -120,7 +120,7 @@ for ep in registry.list_all(): ```python # Via registry (auto-resolved) -records = client.query("engineOverviews").top(5).get() +records = client.query("equipmentOverviews").top(5).get() # Explicit route override records = client.query("myEntity").route("mycompany", "api", "v1.0").top(5).get() diff --git a/finance-2026.05.07-1.tar.gz b/finance-2026.05.07-1.tar.gz index 40a83c2f8b030bf8006ea5a82db904405690a676..4cbe68ee4bbbd9dc335e83dcde97c6ea70b88d8f 100644 GIT binary patch delta 37 tcmaFP@|=ZDzMF%A*J&c#3eJmfvbJ5_b1|lNq#7yuKy4c!0$ delta 37 tcmaFP@|=ZDzMF%g)L|mq3eI)qAvuwkC$!aV+`fX5<2f_STm}sW1^@$b4E+EA diff --git a/src/bcli/client/_async.py b/src/bcli/client/_async.py index a53050f..ea900e6 100644 --- a/src/bcli/client/_async.py +++ b/src/bcli/client/_async.py @@ -747,8 +747,8 @@ def _resolve_url_for_target( # 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). + # they're one typo away (e.g. inspectionStatus → + # inspectionStatuses). suggestions = self._registry.search(entity_set_name)[:3] did_you_mean = "" if suggestions: diff --git a/tests/fixtures/sample_postman_collection.json b/tests/fixtures/sample_postman_collection.json index f870e37..b4ff9a7 100644 --- a/tests/fixtures/sample_postman_collection.json +++ b/tests/fixtures/sample_postman_collection.json @@ -93,7 +93,7 @@ }, { "name": "Technical", - "description": "**Source Table:** \"Engine Overview\"\nTechnical data for equipment tracking.", + "description": "**Source Table:** \"Equipment Overview\"\nTechnical data for equipment tracking.", "item": [ { "name": "Get Equipment Records", diff --git a/tests/test_cli/test_query_cmd.py b/tests/test_cli/test_query_cmd.py index 167033a..3bc5030 100644 --- a/tests/test_cli/test_query_cmd.py +++ b/tests/test_cli/test_query_cmd.py @@ -94,13 +94,13 @@ def test_resolve_params_invalid_format_exits(): def test_expand_query_resolves_param_references(): spec = { - "endpoint": "engineUtilizations", + "endpoint": "equipmentUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", "top": 24, } expanded = _expand_query(spec, {"esn": "100001"}) assert expanded["filter"] == "engineSerialNumber eq '100001'" - assert expanded["endpoint"] == "engineUtilizations" + assert expanded["endpoint"] == "equipmentUtilizations" assert expanded["top"] == 24 @@ -127,7 +127,7 @@ def test_expand_query_escapes_single_quote_in_filter(): def test_expand_query_neutralises_injection_in_filter(): """The example from the security review must no longer break out.""" spec = { - "endpoint": "engineUtilizations", + "endpoint": "equipmentUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", } expanded = _expand_query(spec, {"esn": "100001' or 1 eq 1--"}) diff --git a/tests/test_client/test_transport.py b/tests/test_client/test_transport.py index 4c2b788..d3b70ff 100644 --- a/tests/test_client/test_transport.py +++ b/tests/test_client/test_transport.py @@ -273,15 +273,15 @@ class TestBCErrorHints: def test_property_not_found_emits_endpoint_fields_hint(self): url = ( "https://api.businesscentral.dynamics.com/v2.0/Production/api/" - "contoso/technical/v1.5/companies(abc123)/preservationStatuses" + "contoso/technical/v1.5/companies(abc123)/inspectionStatuses" ) bc_message = ( "Could not find a property named 'postingDate' on type " - "'Microsoft.NAV.preservationStatus'." + "'Microsoft.NAV.inspectionStatus'." ) hint = _hint_for_bc_error(400, bc_message, url) assert hint is not None - assert "bcli endpoint fields preservationStatuses" in hint + assert "bcli endpoint fields inspectionStatuses" in hint def test_property_not_found_with_unparseable_url_falls_back(self): bc_message = ( @@ -312,7 +312,7 @@ async def test_400_with_property_not_found_includes_hint_in_raised_error( "error": { "message": ( "Could not find a property named 'postingDate' on type " - "'Microsoft.NAV.preservationStatus'." + "'Microsoft.NAV.inspectionStatus'." ) } }, @@ -322,7 +322,7 @@ async def test_400_with_property_not_found_includes_hint_in_raised_error( with pytest.raises(ValidationError) as exc: await transport.get( "https://api.businesscentral.dynamics.com/v2.0/Production/api/" - "contoso/technical/v1.5/companies(abc)/preservationStatuses" + "contoso/technical/v1.5/companies(abc)/inspectionStatuses" ) - assert "bcli endpoint fields preservationStatuses" in str(exc.value) + assert "bcli endpoint fields inspectionStatuses" in str(exc.value) diff --git a/tests/test_queries/test_expand.py b/tests/test_queries/test_expand.py index ba02aa4..d61fff0 100644 --- a/tests/test_queries/test_expand.py +++ b/tests/test_queries/test_expand.py @@ -10,13 +10,13 @@ def test_expand_query_resolves_param_references(): spec = { - "endpoint": "engineUtilizations", + "endpoint": "equipmentUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", "top": 24, } resolved = expand_query(spec, {"esn": "100001"}) assert resolved.filter == "engineSerialNumber eq '100001'" - assert resolved.endpoint == "engineUtilizations" + assert resolved.endpoint == "equipmentUtilizations" assert resolved.top == 24 @@ -58,7 +58,7 @@ def test_expand_query_escapes_single_quote_in_filter(): def test_expand_query_neutralises_injection_in_filter(): spec = { - "endpoint": "engineUtilizations", + "endpoint": "equipmentUtilizations", "filter": "engineSerialNumber eq '${{ params.esn }}'", } resolved = expand_query(spec, {"esn": "100001' or 1 eq 1--"}) diff --git a/tests/test_registry/test_metadata_fields.py b/tests/test_registry/test_metadata_fields.py index 29a64bd..33901c8 100644 --- a/tests/test_registry/test_metadata_fields.py +++ b/tests/test_registry/test_metadata_fields.py @@ -12,7 +12,7 @@ - + @@ -21,13 +21,13 @@ - + - - + + @@ -37,7 +37,7 @@ def test_parses_entity_type_properties(): fields = _parse_entity_type_properties(SAMPLE_EDMX) - assert fields["engineUtilization"] == [ + assert fields["equipmentUtilization"] == [ "systemId", "engineSerialNumber", "tailNo", @@ -45,7 +45,7 @@ def test_parses_entity_type_properties(): "efh", "efc", ] - assert fields["engineOverview"] == ["systemId", "engineModel"] + assert fields["equipmentOverview"] == ["systemId", "engineModel"] def test_parse_metadata_xml_attaches_field_names(): @@ -54,17 +54,17 @@ def test_parse_metadata_xml_attaches_field_names(): ) by_name = {ep.entity_set_name: ep for ep in endpoints} - assert "engineUtilizations" in by_name - eu = by_name["engineUtilizations"] + assert "equipmentUtilizations" in by_name + eu = by_name["equipmentUtilizations"] assert eu.api_publisher == "contoso" assert eu.api_group == "technical" assert eu.api_version == "v1.5" - assert eu.entity_name == "engineUtilization" + assert eu.entity_name == "equipmentUtilization" assert "engineSerialNumber" in eu.field_names assert "tailNo" in eu.field_names assert "efh" in eu.field_names - eo = by_name["engineOverviews"] + eo = by_name["equipmentOverviews"] assert eo.field_names == ["systemId", "engineModel"] diff --git a/tests/test_registry/test_registry.py b/tests/test_registry/test_registry.py index 584035f..0ecfab8 100644 --- a/tests/test_registry/test_registry.py +++ b/tests/test_registry/test_registry.py @@ -61,7 +61,7 @@ def test_endpoint_metadata_is_custom(): assert not standard.is_custom custom = EndpointMetadata( - entity_set_name="engineOverviews", + entity_set_name="equipmentOverviews", api_publisher="contoso", api_group="technical", api_version="v1.5", diff --git a/tests/test_url/test_url_builder.py b/tests/test_url/test_url_builder.py index 9b3688a..cc2e1ee 100644 --- a/tests/test_url/test_url_builder.py +++ b/tests/test_url/test_url_builder.py @@ -21,14 +21,14 @@ def test_custom_api_url(): url = build_url( environment="SBEnvOct25T", company_id="abc-123", - entity_set_name="engineOverviews", + entity_set_name="equipmentOverviews", publisher="contoso", group="technical", version="v1.5", ) assert "api/contoso/technical/v1.5" in url assert "SBEnvOct25T" in url - assert url.endswith("/engineOverviews") + assert url.endswith("/equipmentOverviews") def test_url_with_record_id(): @@ -53,7 +53,7 @@ def test_custom_api_validation_path_traversal_dot_dot(): build_url( environment="Production", company_id="abc-123", - entity_set_name="engineOverviews", + entity_set_name="equipmentOverviews", publisher="contoso", group="technical", version="..", @@ -66,7 +66,7 @@ def test_custom_api_validation_slash_in_segment(): build_url( environment="Production", company_id="abc-123", - entity_set_name="engineOverviews", + entity_set_name="equipmentOverviews", publisher="a/b", group="technical", version="v1.5", From f65efa96c9e023ba71229a7c2cfb79b612e10544 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 23:20:24 +0000 Subject: [PATCH 3/5] test: keep bundle test output out of the working directory test_make_bundle_produces_valid_manifest used make_bundle's default output path, dropping finance-2026.05.07-1.tar.gz into the repo root, where it was accidentally committed. Co-authored-by: Igor --- finance-2026.05.07-1.tar.gz | Bin 615 -> 0 bytes tests/test_bundle/test_bundle_roundtrip.py | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 finance-2026.05.07-1.tar.gz diff --git a/finance-2026.05.07-1.tar.gz b/finance-2026.05.07-1.tar.gz deleted file mode 100644 index 4cbe68ee4bbbd9dc335e83dcde97c6ea70b88d8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 615 zcmV-t0+{_DiwFP>LcD4M|LvAdZ`&{ohI8((2z+h=Ss#{^`)!z*&IqzrHs|A=}I2jvWb#8ZfaEv zUX^hlZXQ<1TjuxAarHX&zZmKB`p*c*`u~FGzbnZe2MC)XF8Av-gl>pFM7CZ9_7pYn0>zAC&>F?`lUHK7neA` zhwcF5yO8I4QQel_#$-6e@qTu(WgBBZt5@w@2D6BVF;2r(A9>Y9p6e}{CFkMPEn%0* zJO=(S^=%oh*i9d&kgL0gJ8Vl31VIo4K@bE%5ClOG1VIo4K@j9G`~-&~uLl4q00516 BHS7QY diff --git a/tests/test_bundle/test_bundle_roundtrip.py b/tests/test_bundle/test_bundle_roundtrip.py index 77c5f9b..cf33e54 100644 --- a/tests/test_bundle/test_bundle_roundtrip.py +++ b/tests/test_bundle/test_bundle_roundtrip.py @@ -47,7 +47,8 @@ def _seed_bundle_source(tmp_path: Path) -> Path: return src -def test_make_bundle_produces_valid_manifest(tmp_path): +def test_make_bundle_produces_valid_manifest(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) src = _seed_bundle_source(tmp_path) out, manifest = make_bundle( src, From 1e0799b9b3fed3c8f7906621d1228938162146e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 23:20:24 +0000 Subject: [PATCH 4/5] chore: use only standard Business Central v2.0 entities in examples and tests Replace the remaining aviation-specific examples (engine utilization, ESNs, tail numbers, airworthiness tags, custom purchaseHeaders/Lines pages) with standard v2.0 entities such as customers, salesInvoices, purchaseInvoices and their lines, using CRONUS-style sample values. Custom-route tests use a neutral contoso/integration/v1.0 placeholder. Also drop the 'technical' SafeContext domain rule, which was identical to the default rule. Co-authored-by: Igor --- docs/batch-operations.md | 96 ++++++++++--------- docs/extraction.md | 21 ++-- docs/mcp-server.md | 4 +- docs/saved-queries.md | 38 ++++---- docs/sdk-usage.md | 17 ++-- examples/ap-monthly-review.yaml | 31 +++--- examples/create-purchase-invoice.yaml | 64 +++++++------ examples/extract/purchase_invoice_lines.yaml | 20 ++-- examples/queries/sample.yaml | 4 +- packs/cronus-demo/queries/cronus-sample.yaml | 4 +- src/bcli/bundle/_fetch.py | 2 +- src/bcli/client/_async.py | 4 +- src/bcli/client/_safety.py | 1 - src/bcli/workflow/_loader.py | 2 +- src/bcli_cli/commands/context_cmd.py | 2 +- src/bcli_cli/commands/query_cmd.py | 2 +- src/bcli_cli/commands/skill_init_cmd.py | 2 +- src/bcli_cli/output/_formatters.py | 10 +- tests/fixtures/sample_postman_collection.json | 10 +- tests/test_ask/test_providers.py | 4 +- tests/test_cli/test_data_arg.py | 14 +-- tests/test_cli/test_query_cmd.py | 46 ++++----- tests/test_cli/test_records_format.py | 78 +++++++-------- tests/test_client/test_retry_idempotency.py | 8 +- tests/test_client/test_transport.py | 12 +-- tests/test_config/test_config.py | 2 +- tests/test_describe/test_describe_cmd.py | 16 ++-- tests/test_extract/test_claude.py | 28 +++--- tests/test_extract/test_openai.py | 42 ++++---- tests/test_extract/test_schema.py | 58 +++++------ tests/test_extract/test_yaml_writer.py | 64 ++++++------- tests/test_odata/test_filter_fields.py | 48 +++++----- tests/test_odata/test_query.py | 6 +- tests/test_queries/test_expand.py | 22 ++--- tests/test_queries/test_params.py | 28 +++--- tests/test_registry/test_importers.py | 4 +- tests/test_registry/test_metadata_fields.py | 58 +++++------ tests/test_registry/test_registry.py | 4 +- .../test_skill_init/test_skill_init_wizard.py | 2 +- .../test_skill_install/test_skill_install.py | 62 ++++++------ tests/test_url/test_url_builder.py | 16 ++-- tests/test_workflow/test_loader.py | 15 +-- tests/test_workflow/test_query_search.py | 20 ++-- 43 files changed, 500 insertions(+), 491 deletions(-) diff --git a/docs/batch-operations.md b/docs/batch-operations.md index fc9a662..c52b4a8 100644 --- a/docs/batch-operations.md +++ b/docs/batch-operations.md @@ -24,41 +24,45 @@ bcli batch run workflow.yaml --params month-end.yaml ## Batch File Format ```yaml -name: "Monthly Engine Utilization Upload" +name: "Monthly Sales Invoice for Adatum" steps: - - action: get - endpoint: equipmentOverviews + - name: customer + action: get + endpoint: customers params: - filter: "engineModel eq 'CF34-10E'" - top: 5 + filter: "number eq '10000'" + select: "id,number,displayName" + top: 1 - - action: post - endpoint: equipmentUtilizations + - name: invoice + action: post + endpoint: salesInvoices data: - esn: "ESN-123456" - period: "2026-03" - flightHours: 350.5 - flightCycles: 210 + customerNumber: "${{ steps.customer.0.number }}" + invoiceDate: "2026-03-31" + externalDocumentNumber: "PO-2026-03" - action: post - endpoint: equipmentUtilizations + endpoint: salesInvoiceLines data: - esn: "ESN-789012" - period: "2026-03" - flightHours: 280.0 - flightCycles: 175 - - - action: patch - endpoint: equipmentCards - id: "a1b2c3d4-..." - data: - status: "Available" + documentId: "${{ steps.invoice.id }}" + lineType: "Item" + lineObjectNumber: "1896-S" + quantity: 2 - - action: delete - endpoint: tempRecords - id: "e5f6a7b8-..." + - action: post + endpoint: salesInvoiceLines + data: + documentId: "${{ steps.invoice.id }}" + lineType: "Item" + lineObjectNumber: "1900-S" + quantity: 4 ``` +The GET result feeds the invoice header, and the header's `id` feeds each +line. See [Step Chaining](#step-chaining) for the `${{ steps.... }}` +syntax. + ## Step Actions ### GET @@ -98,7 +102,7 @@ steps: ```yaml - action: delete - endpoint: tempRecords + endpoint: salesQuotes id: "e5f6a7b8-..." ``` @@ -112,17 +116,19 @@ bcli batch run operations.yaml --dry-run Output: ``` -Batch: Monthly Engine Utilization Upload -3 step(s) +Batch: Monthly Sales Invoice for Adatum +4 step(s) - Step 1: GET equipmentOverviews - Params: {'filter': "engineModel eq 'CF34-10E'", 'top': 5} - Step 2: POST equipmentUtilizations - Data: {"esn": "ESN-123456", ...} - Step 3: POST equipmentUtilizations - Data: {"esn": "ESN-789012", ...} + Step 1: GET customers (customer) + Params: {'filter': "number eq '10000'", 'select': 'id,number,displayName', 'top': 1} + Step 2: POST salesInvoices (invoice) + Data: {"customerNumber": "${{ steps.customer.0.number }}", ...} + Step 3: POST salesInvoiceLines + Data: {"documentId": "${{ steps.invoice.id }}", "lineType": "Item", "lineObjectNumber": "1896-S", ...} + Step 4: POST salesInvoiceLines + Data: {"documentId": "${{ steps.invoice.id }}", "lineType": "Item", "lineObjectNumber": "1900-S", ...} ---dry-run: 3 step(s) would execute. +--dry-run: 4 step(s) would execute. ``` ## Error Handling @@ -130,11 +136,12 @@ Batch: Monthly Engine Utilization Upload If a step fails, the error is reported and subsequent steps continue: ``` - Step 1: GET equipmentOverviews... ✓ 5 record(s) - Step 2: POST equipmentUtilizations... ✓ created - Step 3: POST equipmentUtilizations... ✗ HTTP 400: Duplicate record + Step 1: GET customers... ✓ 1 record(s) + Step 2: POST salesInvoices... ✓ created + Step 3: POST salesInvoiceLines... ✓ created + Step 4: POST salesInvoiceLines... ✗ HTTP 400: Blocked must be equal to 'No' in Item: No.=1900-S. Current value is 'Yes'. -✓ Batch complete: 2/3 steps succeeded +✓ Batch complete: 3/4 steps succeeded ``` ## Parameterized Workflows @@ -158,16 +165,15 @@ steps: endpoint: purchaseInvoices params: filter: "vendorNumber eq '${{ params.vendor }}' and postingDate ge ${{ params.month }}-01" - select: "number,vendorNumber,amount,currencyCode,externalDocumentNumber" + select: "number,vendorNumber,totalAmountIncludingTax,currencyCode,vendorInvoiceNumber" top: 500 - - name: ledger + - name: aging action: get - endpoint: vendorLedgerEntries + endpoint: agedAccountsPayables params: - filter: "vendorNumber eq '${{ params.vendor }}' and postingDate ge ${{ params.month }}-01" - select: "documentNumber,amount,currencyCode" - top: 500 + filter: "vendorNumber eq '${{ params.vendor }}'" + select: "vendorNumber,name,currencyCode,balanceDue,currentAmount,period1Amount" ``` Run it: diff --git a/docs/extraction.md b/docs/extraction.md index ec5c47f..283b52f 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -106,23 +106,22 @@ fields: description: "Unit price." output: - endpoint: purchaseLines + endpoint: purchaseInvoiceLines action: post - parent_field: documentNo - parent_param: invoice_no + parent_field: documentId + parent_param: invoice_id field_map: - "no": item_no + lineObjectNumber: item_no description: description quantity: quantity directUnitCost: unit_price constants: - documentType: "Invoice" - type: "Item" + lineType: "Item" ``` -`parent_param` + `parent_field` emit a `${{ params.invoice_no }}` +`parent_param` + `parent_field` emit a `${{ params.invoice_id }}` placeholder in the generated `batch.yaml`. The operator fills it in -before `batch run` (or passes `--set invoice_no=…`). This is the +before `batch run` (or passes `--set invoice_id=…`). This is the intentional human-in-the-loop seam: extraction can't know which BC record the rows belong to, so it asks. @@ -145,16 +144,16 @@ bcli extract run ./invoice-acme-1234.pdf --schema purchase_invoice_lines # Promote to sandbox (dry-run first, then real). bcli batch run invoice-acme-1234.batch.yaml \ - --set invoice_no= \ + --set invoice_id= \ --profile sandbox --dry-run bcli batch run invoice-acme-1234.batch.yaml \ - --set invoice_no= \ + --set invoice_id= \ --profile sandbox # Eyeball in the BC sandbox UI, then production. bcli batch run invoice-acme-1234.batch.yaml \ - --set invoice_no= \ + --set invoice_id= \ --profile production ``` diff --git a/docs/mcp-server.md b/docs/mcp-server.md index aad0875..6865425 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -184,7 +184,7 @@ question. ## Domain-specific MCPs (separate packages) The OSS `bcli-mcp` is intentionally generic. Domain-specific MCPs that combine -BC OData with cross-system data (legal docs, market intel, fleet analytics) +BC OData with data from other systems (CRM, document storage, data warehouse) should live in their own packages, installed alongside this one. The boundary rule: @@ -192,7 +192,7 @@ The boundary rule: * **OSS owns** generic BC transport, query construction, registry, OData escaping, auth, retry, telemetry plumbing. * **Private package owns** domain tool composition only — tools like - `equipment_lookup`, `contract_amendments_for`, `vendor_analytics` that combine + `customer_360`, `vendor_analytics`, `month_end_close_status` that combine multiple BC queries with cross-system data into one named operation. * The private MCP is a *consumer* of `bcli` (subprocess or `from bcli import AsyncBCClient`), never a layer that re-implements diff --git a/docs/saved-queries.md b/docs/saved-queries.md index 6b2408c..934bffd 100644 --- a/docs/saved-queries.md +++ b/docs/saved-queries.md @@ -44,7 +44,7 @@ queries: open-invoices-by-customer: description: Outstanding invoices for a customer - endpoint: customerSalesInvoices + endpoint: salesInvoices params: customer-id: required: true @@ -91,25 +91,25 @@ Example with all the validation knobs: ```yaml queries: - utilization-by-esn: - description: Monthly utilization records for one ESN - endpoint: equipmentUtilizations + invoices-by-customer: + description: Sales invoices for one customer + endpoint: salesInvoices params: - esn: + customer_no: required: true type: string - pattern: "^[A-Za-z0-9-]{4,16}$" # ESNs are alphanumeric, no spaces + pattern: "^[A-Za-z0-9-]{4,20}$" # customer numbers are alphanumeric, no spaces limit: default: 24 type: integer min: 1 max: 1000 - airline: + status: required: false type: string - enum: ["ACMEAIR", "CONTOSO", "FABRIKAM"] - filter: "engineSerialNumber eq '${{ params.esn }}'" - orderby: "asOfDate desc" + enum: ["Draft", "Open", "Paid"] + filter: "customerNumber eq '${{ params.customer_no }}'" + orderby: "invoiceDate desc" top: "${{ params.limit }}" ``` @@ -117,7 +117,7 @@ queries: When a string-typed param is interpolated into the `filter:` field, `bcli` applies OData v4 single-quote escaping (`'` → `''`) so a value like -`100001' or 1 eq 1--` cannot break out of the surrounding string literal. +`10000' or 1 eq 1--` cannot break out of the surrounding string literal. This escape is scoped to the filter context — `select`, `orderby`, `top`, `skip`, `all`, and `endpoint` keep raw values, since they don't sit inside OData string literals. @@ -176,19 +176,19 @@ Three optional fields on each query feed the generator: ```yaml queries: - utilization-by-esn: - description: Engine utilization (cycles, hours, FSN) for an ESN - categories: [aviation, daily-ops] + invoices-by-customer: + description: Sales invoices (dates, totals, status) for one customer + categories: [sales, daily-ops] args: - - name: esn + - name: customer_no type: string - example: "424322" + example: "10000" required: true # existing fields below — params/filter/select/etc. - endpoint: util_history + endpoint: salesInvoices params: - esn: {required: true} - filter: "engine_serial eq '${{ params.esn }}'" + customer_no: {required: true} + filter: "customerNumber eq '${{ params.customer_no }}'" ``` * `description` — used as the slash command's frontmatter `description:` diff --git a/docs/sdk-usage.md b/docs/sdk-usage.md index 348feac..214cb2e 100644 --- a/docs/sdk-usage.md +++ b/docs/sdk-usage.md @@ -118,18 +118,23 @@ for ep in registry.list_all(): ## Custom API Routes +Custom APIs use their own publisher / group / version route. The examples +below use a hypothetical `contoso/integration/v1.0` API exposing +`shipmentTrackings`; see [Custom APIs](custom-apis.md) for how to add one to +the registry. + ```python -# Via registry (auto-resolved) -records = client.query("equipmentOverviews").top(5).get() +# Via registry (auto-resolved once the custom API is imported) +records = client.query("shipmentTrackings").top(5).get() # Explicit route override -records = client.query("myEntity").route("mycompany", "api", "v1.0").top(5).get() +records = client.query("shipmentTrackings").route("contoso", "integration", "v1.0").top(5).get() # Direct with route params response = client.get( - "myEntity", - publisher="mycompany", - group="api", + "shipmentTrackings", + publisher="contoso", + group="integration", version="v1.0", ) ``` diff --git a/examples/ap-monthly-review.yaml b/examples/ap-monthly-review.yaml index f1d88f6..d730fff 100644 --- a/examples/ap-monthly-review.yaml +++ b/examples/ap-monthly-review.yaml @@ -5,33 +5,32 @@ name: "AP Monthly Review — March 2026" steps: - name: Open Purchase Invoices action: get - endpoint: purchaseHeaders + endpoint: purchaseInvoices params: - filter: "documentType eq 'Invoice' and status eq 'Open'" - select: "no,buyFromVendorNo,buyFromVendorName,amount,postingDate,dueDate,poDescription" + filter: "status eq 'Open'" + select: "number,vendorNumber,vendorName,invoiceDate,dueDate,totalAmountIncludingTax" orderby: "dueDate asc" - - name: Vendor Ledger (March open) + - name: Aged Payables action: get - endpoint: vendLedgEntries + endpoint: agedAccountsPayables params: - filter: "postingDate ge 2026-03-01 and postingDate le 2026-03-31 and open eq true" - select: "vendorNo,vendorName,documentType,documentNo,amount,dueDate" - orderby: "dueDate asc" + select: "vendorNumber,name,currencyCode,balanceDue,currentAmount,period1Amount,period2Amount,period3Amount" + orderby: "balanceDue desc" - - name: GL Entries (AP journal, March) + - name: GL Entries (March) action: get - endpoint: glEntries + endpoint: generalLedgerEntries params: - filter: "postingDate ge 2026-03-01 and sourceCode eq 'PURCHJNL'" - select: "postingDate,documentNo,description,amount,sourceCode" + filter: "postingDate ge 2026-03-01 and postingDate le 2026-03-31" + select: "entryNumber,postingDate,documentNumber,accountNumber,description,debitAmount,creditAmount" top: "50" orderby: "postingDate desc" - - name: Overdue Items + - name: Overdue Invoices action: get - endpoint: vendLedgEntries + endpoint: purchaseInvoices params: - filter: "open eq true and dueDate lt 2026-03-31" - select: "vendorNo,vendorName,documentNo,amount,dueDate" + filter: "status eq 'Open' and dueDate lt 2026-03-31" + select: "number,vendorNumber,vendorName,dueDate,totalAmountIncludingTax" orderby: "dueDate asc" diff --git a/examples/create-purchase-invoice.yaml b/examples/create-purchase-invoice.yaml index 1e23dcb..a058efa 100644 --- a/examples/create-purchase-invoice.yaml +++ b/examples/create-purchase-invoice.yaml @@ -1,22 +1,27 @@ -name: "Create Draft Purchase Invoice — AAR Supply Chain" -# Run: bcli batch run examples/create-purchase-invoice.yaml --set vendor_no=V00011 -# Dry run: bcli batch run examples/create-purchase-invoice.yaml --set vendor_no=V00011 --dry-run +name: "Create Draft Purchase Invoice" +# Uses the standard v2.0 purchaseInvoices / purchaseInvoiceLines entities. # -# Step chaining: the invoice number from step 1 flows into steps 2 and 3 -# via ${{ steps.create_header.no }}. +# Run: bcli batch run examples/create-purchase-invoice.yaml --set vendor_no=V00011 --set gl_account= +# Dry run: add --dry-run +# +# Step chaining: the invoice id from step 1 flows into steps 2 and 3 +# via ${{ steps.create_header.id }}. params: vendor_no: description: "Vendor number (e.g. V00011)" required: true - posting_desc: - description: "Invoice posting description" - default: "Engine inspection services — March 2026" - inspection_desc: - description: "Description for the inspection line" - default: "CF34-10E borescope inspection" - inspection_cost: - description: "Inspection cost" + gl_account: + description: "G/L account number to expense the lines to" + required: true + vendor_invoice_no: + description: "Vendor's own invoice number" + default: "INV-2026-0315" + service_desc: + description: "Description for the service line" + default: "Implementation consulting — 30 hours" + service_cost: + description: "Service fee" default: 4500 travel_cost: description: "Travel expenses" @@ -26,34 +31,31 @@ steps: # 1. Create the invoice header - name: create_header action: post - endpoint: purchaseHeaders + endpoint: purchaseInvoices data: - documentType: "Invoice" - buyFromVendorNo: "${{ params.vendor_no }}" - postingDescription: "${{ params.posting_desc }}" + vendorNumber: "${{ params.vendor_no }}" + vendorInvoiceNumber: "${{ params.vendor_invoice_no }}" - # 2. Add first line (inspection fee) - - name: add_inspection_line + # 2. Add first line (service fee) + - name: add_service_line action: post - endpoint: purchaseLines + endpoint: purchaseInvoiceLines data: - documentType: "Invoice" - documentNo: "${{ steps.create_header.no }}" - type: "G_x002F_L_x0020_Account" - "no": "6260-000000-000" - description: "${{ params.inspection_desc }}" + documentId: "${{ steps.create_header.id }}" + lineType: "Account" + lineObjectNumber: "${{ params.gl_account }}" + description: "${{ params.service_desc }}" quantity: 1 - directUnitCost: ${{ params.inspection_cost }} + directUnitCost: ${{ params.service_cost }} # 3. Add second line (travel expenses) - name: add_travel_line action: post - endpoint: purchaseLines + endpoint: purchaseInvoiceLines data: - documentType: "Invoice" - documentNo: "${{ steps.create_header.no }}" - type: "G_x002F_L_x0020_Account" - "no": "6260-000000-000" + documentId: "${{ steps.create_header.id }}" + lineType: "Account" + lineObjectNumber: "${{ params.gl_account }}" description: "Travel and per diem" quantity: 1 directUnitCost: ${{ params.travel_cost }} diff --git a/examples/extract/purchase_invoice_lines.yaml b/examples/extract/purchase_invoice_lines.yaml index 759012e..6c753a3 100644 --- a/examples/extract/purchase_invoice_lines.yaml +++ b/examples/extract/purchase_invoice_lines.yaml @@ -4,10 +4,11 @@ description: | invoice. One PDF typically contains many line items as a single table; this schema captures the common columns. - Adjust the field map to match your tenant's purchaseLines field - names. Treat this file as a starting point, not production-ready — - every supplier formats invoices differently, and the prompt below - may need sharpening for your specific vendor templates. + Adjust the field map if your purchaseInvoiceLines setup differs + (for example, G/L account lines instead of items). Treat this file + as a starting point, not production-ready — every supplier formats + invoices differently, and the prompt below may need sharpening for + your specific vendor templates. prompt: | This PDF is a vendor purchase invoice. Extract one record per @@ -47,15 +48,14 @@ fields: description: "Line total (quantity × unit_price). Numeric only." output: - endpoint: purchaseLines + endpoint: purchaseInvoiceLines action: post - parent_field: documentNo - parent_param: invoice_no + parent_field: documentId + parent_param: invoice_id field_map: - "no": item_no + lineObjectNumber: item_no description: description quantity: quantity directUnitCost: unit_price constants: - documentType: "Invoice" - type: "Item" + lineType: "Item" diff --git a/examples/queries/sample.yaml b/examples/queries/sample.yaml index 9c72724..54f4920 100644 --- a/examples/queries/sample.yaml +++ b/examples/queries/sample.yaml @@ -37,12 +37,12 @@ queries: # enum are all enforced locally before the HTTP call. open-invoices-by-customer: description: Open sales invoices for a given customer - endpoint: customerSalesInvoices + endpoint: salesInvoices params: customer-id: required: true type: string - pattern: "^C[0-9]{5}$" # rejects free-form text like "' or 1 eq 1--" + pattern: "^[0-9]{5}$" # rejects free-form text like "10000' or 1 eq 1--" limit: default: 50 type: integer diff --git a/packs/cronus-demo/queries/cronus-sample.yaml b/packs/cronus-demo/queries/cronus-sample.yaml index 9c72724..54f4920 100644 --- a/packs/cronus-demo/queries/cronus-sample.yaml +++ b/packs/cronus-demo/queries/cronus-sample.yaml @@ -37,12 +37,12 @@ queries: # enum are all enforced locally before the HTTP call. open-invoices-by-customer: description: Open sales invoices for a given customer - endpoint: customerSalesInvoices + endpoint: salesInvoices params: customer-id: required: true type: string - pattern: "^C[0-9]{5}$" # rejects free-form text like "' or 1 eq 1--" + pattern: "^[0-9]{5}$" # rejects free-form text like "10000' or 1 eq 1--" limit: default: 50 type: integer diff --git a/src/bcli/bundle/_fetch.py b/src/bcli/bundle/_fetch.py index 95d587e..3779c21 100644 --- a/src/bcli/bundle/_fetch.py +++ b/src/bcli/bundle/_fetch.py @@ -36,7 +36,7 @@ logger = logging.getLogger("bcli.bundle.fetch") -# Hard ceilings. A real finance/engine bundle is small (KB-MB range); +# Hard ceilings. A real finance bundle is small (KB-MB range); # anything outside these is either misconfigured or hostile. MAX_COMPRESSED_BYTES = 25 * 1024 * 1024 # 25 MB on the wire MAX_TOTAL_EXTRACTED_BYTES = 100 * 1024 * 1024 # 100 MB after gunzip diff --git a/src/bcli/client/_async.py b/src/bcli/client/_async.py index ea900e6..bea4070 100644 --- a/src/bcli/client/_async.py +++ b/src/bcli/client/_async.py @@ -747,8 +747,8 @@ def _resolve_url_for_target( # 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. inspectionStatus → - # inspectionStatuses). + # they're one typo away (e.g. agedAccountsPayable → + # agedAccountsPayables). suggestions = self._registry.search(entity_set_name)[:3] did_you_mean = "" if suggestions: diff --git a/src/bcli/client/_safety.py b/src/bcli/client/_safety.py index ab7f33c..50b8b67 100644 --- a/src/bcli/client/_safety.py +++ b/src/bcli/client/_safety.py @@ -32,7 +32,6 @@ class DomainRule: # Default domain rules — override with SafeContext(domain_rules={...}) DEFAULT_DOMAIN_RULES: dict[str, DomainRule] = { "finance": DomainRule(require_draft=True, require_production_confirm=True), - "technical": DomainRule(require_draft=False, require_production_confirm=True), "standard": DomainRule(require_draft=False, require_production_confirm=True), } diff --git a/src/bcli/workflow/_loader.py b/src/bcli/workflow/_loader.py index 3acfbbc..defd352 100644 --- a/src/bcli/workflow/_loader.py +++ b/src/bcli/workflow/_loader.py @@ -4,7 +4,7 @@ ``off:`` as booleans. BC fields use ``no`` as a primary-key field (account number, line number) — silently coercing those to ``False`` produces broken workflows that hit BC with payloads like -``{"false": "6260-..."}`` instead of ``{"no": "6260-..."}``. +``{"false": "8430"}`` instead of ``{"no": "8430"}``. Rather than coerce-and-guess (which spelling did the user intend — ``no``? ``No``? ``NO``?), this loader rejects boolean keys with a diff --git a/src/bcli_cli/commands/context_cmd.py b/src/bcli_cli/commands/context_cmd.py index e2c1388..5a3b9e8 100644 --- a/src/bcli_cli/commands/context_cmd.py +++ b/src/bcli_cli/commands/context_cmd.py @@ -71,7 +71,7 @@ # Discover endpoints bcli endpoint list # All endpoints bcli endpoint list --custom # Only custom API endpoints -bcli endpoint search engine # Fuzzy search +bcli endpoint search invoice # Fuzzy search bcli endpoint info customers # Metadata for one endpoint bcli endpoint fields customers # Field names + types from live data ``` diff --git a/src/bcli_cli/commands/query_cmd.py b/src/bcli_cli/commands/query_cmd.py index f44151f..2fd66b7 100644 --- a/src/bcli_cli/commands/query_cmd.py +++ b/src/bcli_cli/commands/query_cmd.py @@ -106,7 +106,7 @@ def query_command( Examples: bcli q # list saved queries for this profile bcli q customer-by-name name=Fabrikam - bcli q open-invoices-by-customer customer-id=C00010 limit=20 + bcli q open-invoices-by-customer customer-id=10000 limit=20 bcli q customer-by-name name=Fabrikam --show bcli q search "overdue invoices" # discover by NL phrase bcli q info customer-by-name # full metadata for one query diff --git a/src/bcli_cli/commands/skill_init_cmd.py b/src/bcli_cli/commands/skill_init_cmd.py index 9276630..7c59311 100644 --- a/src/bcli_cli/commands/skill_init_cmd.py +++ b/src/bcli_cli/commands/skill_init_cmd.py @@ -353,7 +353,7 @@ def _collect_proposed_new_queries( def _interview_interactively() -> InterviewState: """Ask the four contract-doc-mandated questions via Rich prompts.""" role = Prompt.ask( - "Role (finance / ops / aviation / sales / dev / custom)", + "Role (finance / ops / sales / dev / custom)", default="custom", ) top_three = Prompt.ask( diff --git a/src/bcli_cli/output/_formatters.py b/src/bcli_cli/output/_formatters.py index e265913..829916b 100644 --- a/src/bcli_cli/output/_formatters.py +++ b/src/bcli_cli/output/_formatters.py @@ -115,7 +115,7 @@ def _should_auto_records(records: list[dict[str, Any]]) -> bool: return False # Always flip when there are very many columns — terminal width can't # be trusted on Windows Terminal under some shells, and 8+ columns in - # 1-2 rows is the canonical "give me the engine record" lookup shape. + # 1-2 rows is the canonical "give me this customer record" lookup shape. if len(columns) > 8: return True width = shutil.get_terminal_size((120, 24)).columns @@ -254,16 +254,16 @@ def _format_records(records: list[dict[str, Any]]) -> None: Output shape (line-wrapping aside, every field stays on its own line): record 1 - systemId : b1fc5e63-… - engineSerialNumber : 100002 - engineType : CF34-8C + id : b1fc5e63-… + number : 10000 + displayName : Adatum Corporation … record 2 … This is the right view for wide records on narrow terminals (Windows - PowerShell with ~120 columns and an engine record with ~30 fields is the + PowerShell with ~120 columns and a customer record with ~30 fields is the motivating case). It works in any terminal — no box-drawing, no ANSI, safe to redirect. """ diff --git a/tests/fixtures/sample_postman_collection.json b/tests/fixtures/sample_postman_collection.json index b4ff9a7..ef39e6c 100644 --- a/tests/fixtures/sample_postman_collection.json +++ b/tests/fixtures/sample_postman_collection.json @@ -92,11 +92,11 @@ ] }, { - "name": "Technical", - "description": "**Source Table:** \"Equipment Overview\"\nTechnical data for equipment tracking.", + "name": "Integration", + "description": "**Source Table:** \"Shipment Tracking\"\nShipment tracking exposed as a custom API.", "item": [ { - "name": "Get Equipment Records", + "name": "Get Shipment Trackings", "request": { "method": "GET", "url": { @@ -105,10 +105,10 @@ "{{env}}", "api", "acme", - "technical", + "integration", "v1.0", "companies({{company_id}})", - "equipmentRecords" + "shipmentTrackings" ] } } diff --git a/tests/test_ask/test_providers.py b/tests/test_ask/test_providers.py index b8d46c4..a01622b 100644 --- a/tests/test_ask/test_providers.py +++ b/tests/test_ask/test_providers.py @@ -43,7 +43,7 @@ def test_collect_extra_context_runs_only_enabled( monkeypatch, """ def provider(profile, last_error): - return {"glossary.ESN": "Engine Serial Number"} + return {"glossary.GL": "General Ledger"} """, name="_fake_provider_a", ) @@ -75,7 +75,7 @@ def provider(profile, last_error): last_error=None, enabled=["alpha"], # only alpha — beta must NOT run ) - assert out == {"glossary.ESN": "Engine Serial Number"} + assert out == {"glossary.GL": "General Ledger"} def test_provider_failure_is_logged_not_raised( diff --git a/tests/test_cli/test_data_arg.py b/tests/test_cli/test_data_arg.py index fce4c66..f70983b 100644 --- a/tests/test_cli/test_data_arg.py +++ b/tests/test_cli/test_data_arg.py @@ -124,20 +124,20 @@ class TestShellManglingTrap: def test_no_quotes_at_all_hints_shell_mangling(self): with pytest.raises(typer.BadParameter) as exc_info: - parse_data_argument("{bladeType: HPT BLADE}") + parse_data_argument("{displayName: ATHENS Desk}") msg = str(exc_info.value) assert "shell" in msg.lower() assert "-d @payload.json" in msg def test_unquoted_keys_with_some_quotes_hints_shell_mangling(self): with pytest.raises(typer.BadParameter) as exc_info: - parse_data_argument('{bladeType: "HPT BLADE", qty: 5}') + parse_data_argument('{displayName: "ATHENS Desk", quantity: 5}') msg = str(exc_info.value) assert "shell" in msg.lower() def test_hint_is_a_single_sentence(self): with pytest.raises(typer.BadParameter) as exc_info: - parse_data_argument("{bladeType: HPT BLADE}") + parse_data_argument("{displayName: ATHENS Desk}") hint_line = str(exc_info.value).splitlines()[-1].strip() # One sentence: exactly one terminal period, not a lecture. (The # "e.g." abbreviation has its own internal periods, so strip it @@ -208,13 +208,13 @@ class TestBomTolerantAtFile: def test_bom_prefixed_file_parses(self, tmp_path): p = tmp_path / "payload.json" - p.write_bytes(b'\xef\xbb\xbf{"bladeType": "FAN BLADE"}') - assert parse_data_argument(f"@{p}") == {"bladeType": "FAN BLADE"} + p.write_bytes(b'\xef\xbb\xbf{"displayName": "ATHENS Mobile Pedestal"}') + assert parse_data_argument(f"@{p}") == {"displayName": "ATHENS Mobile Pedestal"} def test_plain_utf8_file_still_parses(self, tmp_path): p = tmp_path / "payload.json" - p.write_bytes(b'{"bladeType": "HPT BLADE"}') - assert parse_data_argument(f"@{p}") == {"bladeType": "HPT BLADE"} + p.write_bytes(b'{"displayName": "ATHENS Desk"}') + assert parse_data_argument(f"@{p}") == {"displayName": "ATHENS Desk"} def test_non_ascii_without_bom_still_parses(self, tmp_path): p = tmp_path / "payload.json" diff --git a/tests/test_cli/test_query_cmd.py b/tests/test_cli/test_query_cmd.py index 3bc5030..a60db83 100644 --- a/tests/test_cli/test_query_cmd.py +++ b/tests/test_cli/test_query_cmd.py @@ -68,20 +68,20 @@ def test_resolve_params_cli_overrides_default(): def test_resolve_params_required_missing_exits(): - declared = {"esn": {"required": True}} + declared = {"customer_no": {"required": True}} with pytest.raises(typer.Exit): _resolve_params(declared, []) def test_resolve_params_required_supplied(): - declared = {"esn": {"required": True}} - resolved = _resolve_params(declared, ["esn=100001"]) - assert resolved == {"esn": 100001} # YAML coerces digits + declared = {"customer_no": {"required": True}} + resolved = _resolve_params(declared, ["customer_no=10000"]) + assert resolved == {"customer_no": 10000} # YAML coerces digits def test_resolve_params_string_value(): - resolved = _resolve_params({"airline": {"required": True}}, ["airline=ACMEAIR"]) - assert resolved == {"airline": "ACMEAIR"} + resolved = _resolve_params({"currency": {"required": True}}, ["currency=USD"]) + assert resolved == {"currency": "USD"} def test_resolve_params_invalid_format_exits(): @@ -94,13 +94,13 @@ def test_resolve_params_invalid_format_exits(): def test_expand_query_resolves_param_references(): spec = { - "endpoint": "equipmentUtilizations", - "filter": "engineSerialNumber eq '${{ params.esn }}'", + "endpoint": "salesInvoices", + "filter": "customerNumber eq '${{ params.customer_no }}'", "top": 24, } - expanded = _expand_query(spec, {"esn": "100001"}) - assert expanded["filter"] == "engineSerialNumber eq '100001'" - assert expanded["endpoint"] == "equipmentUtilizations" + expanded = _expand_query(spec, {"customer_no": "10000"}) + assert expanded["filter"] == "customerNumber eq '10000'" + assert expanded["endpoint"] == "salesInvoices" assert expanded["top"] == 24 @@ -127,13 +127,13 @@ def test_expand_query_escapes_single_quote_in_filter(): def test_expand_query_neutralises_injection_in_filter(): """The example from the security review must no longer break out.""" spec = { - "endpoint": "equipmentUtilizations", - "filter": "engineSerialNumber eq '${{ params.esn }}'", + "endpoint": "salesInvoices", + "filter": "customerNumber eq '${{ params.customer_no }}'", } - expanded = _expand_query(spec, {"esn": "100001' or 1 eq 1--"}) + expanded = _expand_query(spec, {"customer_no": "10000' or 1 eq 1--"}) # The injected quote is doubled, so the literal stays well-formed and the # ``or 1 eq 1--`` ends up inside the string instead of as new operators. - assert expanded["filter"] == "engineSerialNumber eq '100001'' or 1 eq 1--'" + assert expanded["filter"] == "customerNumber eq '10000'' or 1 eq 1--'" assert expanded["filter"].count("'") % 2 == 0 @@ -198,20 +198,20 @@ def test_integer_within_bounds_accepted(self): assert resolved == {"limit": 50} def test_string_pattern_accepts_match(self): - declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} - resolved = _resolve_params(declared, ["airline=ACMEAIR"]) - assert resolved == {"airline": "ACMEAIR"} + declared = {"currency": {"required": True, "type": "string", "pattern": r"^[A-Z]{3}$"}} + resolved = _resolve_params(declared, ["currency=USD"]) + assert resolved == {"currency": "USD"} def test_string_pattern_rejects_non_match(self): - declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} + declared = {"currency": {"required": True, "type": "string", "pattern": r"^[A-Z]{3}$"}} with pytest.raises(typer.Exit): - _resolve_params(declared, ["airline=little caesars"]) + _resolve_params(declared, ["currency=little caesars"]) def test_string_pattern_rejects_injection_attempt(self): - """The injection example from the review fails the ESN pattern check.""" - declared = {"esn": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} + """The injection example from the review fails the customer-number pattern check.""" + declared = {"customer_no": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} with pytest.raises(typer.Exit): - _resolve_params(declared, ["esn=100001' or 1 eq 1--"]) + _resolve_params(declared, ["customer_no=10000' or 1 eq 1--"]) def test_enum_accepts_valid(self): declared = {"status": {"required": True, "enum": ["Open", "Posted"]}} diff --git a/tests/test_cli/test_records_format.py b/tests/test_cli/test_records_format.py index a1e3cec..d661ffe 100644 --- a/tests/test_cli/test_records_format.py +++ b/tests/test_cli/test_records_format.py @@ -11,38 +11,38 @@ def _wide_record() -> dict: - """30-column engine record — the canonical "too wide for table" case.""" + """30-column customer record — the canonical "too wide for table" case.""" return { - "systemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "engineSerialNumber": "100002", - "engineType": "CF34-8C", - "engineModel": "CF34-8C5", - "thrustRating": "8C5", - "initialDate": "2024-01-01", - "lastOperationDate": "2025-01-01", - "initialEngineTsn": "10000.00", - "intialEngineCsn": "8000", - "fc": "100", - "fh": "200.00", - "lastInvoiceDate": "2026-01-31", - "currentTsn": "10200.00", - "currentCsn": "8100", - "limiter": "9000", - "tspr": "10200.00", - "cspr": "8100", - "tslsv": "0", - "cslsv": "0", - "aprCycles": "10", - "qec": "Neutral", - "ittMargin": "30", - "ittMarginNotes": "Example ECM note", - "serviceable": "Yes", + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "number": "10000", + "displayName": "Adatum Corporation", + "type": "Company", + "addressLine1": "Station Road, 21", + "addressLine2": "", + "city": "Cambridge", + "state": "", + "country": "GB", + "postalCode": "CB1 2FB", + "phoneNumber": "", + "email": "robert.townes@contoso.com", + "website": "", + "contactName": "Robert Townes", + "salespersonCode": "PS", + "balanceDue": "1499.03", + "creditLimit": "0", + "taxLiable": "false", + "taxAreaId": "00000000-0000-0000-0000-000000000000", + "taxAreaDisplayName": "", + "taxRegistrationNumber": "789456278", + "currencyId": "00000000-0000-0000-0000-000000000000", + "currencyCode": "USD", + "paymentTermsId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "shipmentMethodId": "00000000-0000-0000-0000-000000000000", + "paymentMethodId": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "priceGroupCode": "RETAIL", + "blocked": " ", "notes": "Example note", - "lessee": "", - "operator": "", - "engineStatus": "Available", - "location": "Example Location", - "systemModifiedAt": "2026-01-01T00:00:00.000Z", + "lastModifiedDateTime": "2026-01-01T00:00:00.000Z", } @@ -68,20 +68,20 @@ def test_env_kill_switch(self, monkeypatch): def test_format_output_promotes_table_to_records_for_wide_single(self, capsys): format_output([_wide_record()], fmt="table") out = capsys.readouterr().out - assert "engineSerialNumber : 100002" in out - assert "engineType : CF34-8C" in out + assert "number : 10000" in out + assert "displayName : Adatum Corporation" in out def test_format_output_promotes_markdown_to_records_for_wide_single(self, capsys): format_output([_wide_record()], fmt="markdown") out = capsys.readouterr().out # Markdown table never appears — the auto-fallback fires first. - assert "| systemId" not in out - assert "engineSerialNumber : 100002" in out + assert "| id " not in out + assert "number : 10000" in out def test_explicit_records_format_works(self, capsys): format_output([_wide_record()], fmt="records") out = capsys.readouterr().out - assert "engineSerialNumber : 100002" in out + assert "number : 10000" in out def test_records_alias_r_works(self, capsys): format_output([{"a": "1", "b": "2"}], fmt="r") @@ -93,16 +93,16 @@ def test_csv_format_is_not_promoted(self, capsys): format_output([_wide_record()], fmt="csv") out = capsys.readouterr().out # CSV header line ends with a comma-separated set, not "name : value". - assert "engineSerialNumber" in out - assert "engineSerialNumber : 100002" not in out + assert "taxRegistrationNumber" in out + assert "number : 10000" not in out def test_explicit_format_disables_auto_promote(self, capsys): """`-f markdown` is a contract; honor it even on a wide single row.""" format_output([_wide_record()], fmt="markdown", auto_format=False) out = capsys.readouterr().out # Markdown header pipe present, vertical view absent. - assert "| systemId" in out - assert "engineSerialNumber : 100002" not in out + assert "| id " in out + assert "number : 10000" not in out class TestRecordsRendering: diff --git a/tests/test_client/test_retry_idempotency.py b/tests/test_client/test_retry_idempotency.py index 6b16c56..36be1b8 100644 --- a/tests/test_client/test_retry_idempotency.py +++ b/tests/test_client/test_retry_idempotency.py @@ -5,10 +5,10 @@ have applied the request when the response was lost, so a retry duplicates a create or re-runs a business action. -That is not theoretical for this SDK. Bound actions like the LLP-utilisation -recalculation take no arguments and mutate on every invocation — there is no -such thing as a harmless repeat. A single 503 from a gateway could recalculate -twice, and nothing in the log would say so. +That is not theoretical for this SDK. Bound actions like ``Microsoft.NAV.post`` +on a sales invoice take no arguments and mutate on every invocation — there is +no such thing as a harmless repeat. A single 503 from a gateway could fire the +action twice, and nothing in the log would say so. So retries are now allowed only when repeating the request is safe: diff --git a/tests/test_client/test_transport.py b/tests/test_client/test_transport.py index d3b70ff..f42528b 100644 --- a/tests/test_client/test_transport.py +++ b/tests/test_client/test_transport.py @@ -273,15 +273,15 @@ class TestBCErrorHints: def test_property_not_found_emits_endpoint_fields_hint(self): url = ( "https://api.businesscentral.dynamics.com/v2.0/Production/api/" - "contoso/technical/v1.5/companies(abc123)/inspectionStatuses" + "contoso/integration/v1.5/companies(abc123)/shipmentTrackings" ) bc_message = ( "Could not find a property named 'postingDate' on type " - "'Microsoft.NAV.inspectionStatus'." + "'Microsoft.NAV.shipmentTracking'." ) hint = _hint_for_bc_error(400, bc_message, url) assert hint is not None - assert "bcli endpoint fields inspectionStatuses" in hint + assert "bcli endpoint fields shipmentTrackings" in hint def test_property_not_found_with_unparseable_url_falls_back(self): bc_message = ( @@ -312,7 +312,7 @@ async def test_400_with_property_not_found_includes_hint_in_raised_error( "error": { "message": ( "Could not find a property named 'postingDate' on type " - "'Microsoft.NAV.inspectionStatus'." + "'Microsoft.NAV.shipmentTracking'." ) } }, @@ -322,7 +322,7 @@ async def test_400_with_property_not_found_includes_hint_in_raised_error( with pytest.raises(ValidationError) as exc: await transport.get( "https://api.businesscentral.dynamics.com/v2.0/Production/api/" - "contoso/technical/v1.5/companies(abc)/inspectionStatuses" + "contoso/integration/v1.5/companies(abc)/shipmentTrackings" ) - assert "bcli endpoint fields inspectionStatuses" in str(exc.value) + assert "bcli endpoint fields shipmentTrackings" in str(exc.value) diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index 9e715ec..2fc93e2 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -60,7 +60,7 @@ def test_profile_model(): client_id="xyz", client_secret_env="MY_SECRET", api_publisher="contoso", - api_group="technical", + api_group="integration", api_version="v1.5", ) assert p.auth_method == "client_credentials" diff --git a/tests/test_describe/test_describe_cmd.py b/tests/test_describe/test_describe_cmd.py index affc8aa..dedc4d9 100644 --- a/tests/test_describe/test_describe_cmd.py +++ b/tests/test_describe/test_describe_cmd.py @@ -109,16 +109,16 @@ def _write_custom_registry(registries_dir: Path, profile: str = "test") -> Path: "field_names": ["id", "number", "name"], }, { - "entity_set_name": "engineLogbook", - "entity_name": "EngineLogbook", - "description": "Aviation engine logbook", + "entity_set_name": "shipmentTrackings", + "entity_name": "shipmentTracking", + "description": "Shipment tracking records", "supports": ["GET"], "key_field": "id", - "category": "aviation", + "category": "sales", "api_publisher": "contoso", - "api_group": "aviation", + "api_group": "integration", "api_version": "v1.0", - "domain": "technical", + "domain": "standard", "field_names": [], }, ] @@ -289,14 +289,14 @@ def test_describe_profile_constraints_projection(tmp_config): tmp_config["config_file"], disable_writes=True, disable_standard_api=True, - allowed_categories=["finance", "aviation"], + allowed_categories=["finance", "sales"], ) result = _invoke_describe("--format", "json") data = json.loads(result.stdout) constraints = data["profile_constraints"] assert constraints["disable_writes"] is True assert constraints["disable_standard_api"] is True - assert constraints["allowed_categories"] == ["finance", "aviation"] + assert constraints["allowed_categories"] == ["finance", "sales"] def test_describe_profile_constraints_defaults_to_unset(tmp_config): diff --git a/tests/test_extract/test_claude.py b/tests/test_extract/test_claude.py index e4d832c..f674eb2 100644 --- a/tests/test_extract/test_claude.py +++ b/tests/test_extract/test_claude.py @@ -33,18 +33,18 @@ def _list_schema(tmp_path: Path): return _schema( tmp_path, """ -name: "tags" +name: "invoice-lines" prompt: "extract" list: true fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) @@ -81,15 +81,15 @@ def _response(records: list[dict[str, Any]]) -> SimpleNamespace: def test_extract_parses_tool_use_response(tmp_path: Path) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) fake = _FakeAnthropic( _response( [ - {"part_no": "PN-1", "source_pages": [1]}, - {"part_no": "PN-2", "source_pages": [2]}, + {"item_no": "1896-S", "source_pages": [1]}, + {"item_no": "1906-S", "source_pages": [2]}, ] ) ) @@ -105,7 +105,7 @@ def test_extract_parses_tool_use_response(tmp_path: Path) -> None: result = extractor.extract(pdf, schema) assert len(result.records) == 2 - assert result.records[0].fields == {"part_no": "PN-1"} + assert result.records[0].fields == {"item_no": "1896-S"} assert result.records[0].source_pages == (1,) assert result.input_tokens == 42 assert result.output_tokens == 7 @@ -144,7 +144,7 @@ def test_extract_oversize_pdf_short_circuits_before_call(tmp_path: Path) -> None def test_missing_tool_use_raises(tmp_path: Path) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) fake = _FakeAnthropic( @@ -177,21 +177,21 @@ def test_list_false_schema_warns_on_extra_records(tmp_path: Path) -> None: prompt: "one" list: false fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) fake = _FakeAnthropic( _response( [ - {"part_no": "PN-1", "source_pages": [1]}, - {"part_no": "PN-2", "source_pages": [2]}, + {"item_no": "1896-S", "source_pages": [1]}, + {"item_no": "1906-S", "source_pages": [2]}, ] ) ) diff --git a/tests/test_extract/test_openai.py b/tests/test_extract/test_openai.py index dee0a87..324bf13 100644 --- a/tests/test_extract/test_openai.py +++ b/tests/test_extract/test_openai.py @@ -34,18 +34,18 @@ def _list_schema(tmp_path: Path): return _schema( tmp_path, """ -name: "tags" +name: "invoice-lines" prompt: "extract" list: true fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) @@ -124,15 +124,15 @@ def extractor() -> OpenAIExtractor: def test_extract_parses_output_text( tmp_path: Path, extractor: OpenAIExtractor ) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) fake = _FakeOpenAI( _response_via_output_text( [ - {"part_no": "PN-1", "source_pages": [1]}, - {"part_no": "PN-2", "source_pages": [2]}, + {"item_no": "1896-S", "source_pages": [1]}, + {"item_no": "1906-S", "source_pages": [2]}, ] ) ) @@ -141,7 +141,7 @@ def test_extract_parses_output_text( result = extractor.extract(pdf, schema) assert len(result.records) == 2 - assert result.records[0].fields == {"part_no": "PN-1"} + assert result.records[0].fields == {"item_no": "1896-S"} assert result.records[0].source_pages == (1,) assert result.input_tokens == 100 assert result.output_tokens == 20 @@ -166,28 +166,28 @@ def test_extract_parses_output_text( def test_extract_parses_output_parsed( tmp_path: Path, extractor: OpenAIExtractor ) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) extractor._client = _FakeOpenAI( - _response_via_parsed([{"part_no": "PN-X", "source_pages": [3]}]) + _response_via_parsed([{"item_no": "1908-S", "source_pages": [3]}]) ) result = extractor.extract(pdf, schema) - assert result.records[0].fields == {"part_no": "PN-X"} + assert result.records[0].fields == {"item_no": "1908-S"} assert result.records[0].source_pages == (3,) def test_extract_parses_output_walk( tmp_path: Path, extractor: OpenAIExtractor ) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) extractor._client = _FakeOpenAI( - _response_via_output_walk([{"part_no": "PN-W", "source_pages": [5]}]) + _response_via_output_walk([{"item_no": "1920-S", "source_pages": [5]}]) ) result = extractor.extract(pdf, schema) - assert result.records[0].fields == {"part_no": "PN-W"} + assert result.records[0].fields == {"item_no": "1920-S"} def test_extract_oversize_pdf_short_circuits(tmp_path: Path) -> None: @@ -212,7 +212,7 @@ def test_extract_oversize_pdf_short_circuits(tmp_path: Path) -> None: def test_extract_missing_payload_raises( tmp_path: Path, extractor: OpenAIExtractor ) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) extractor._client = _FakeOpenAI( @@ -225,7 +225,7 @@ def test_extract_missing_payload_raises( def test_uploaded_file_deleted_even_on_response_failure( tmp_path: Path, extractor: OpenAIExtractor ) -> None: - pdf = tmp_path / "blades.pdf" + pdf = tmp_path / "invoice.pdf" _make_pdf(pdf) schema = _list_schema(tmp_path) @@ -257,21 +257,21 @@ def test_list_false_schema_warns_on_extra_records( prompt: "one" list: false fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) extractor._client = _FakeOpenAI( _response_via_output_text( [ - {"part_no": "PN-1", "source_pages": [1]}, - {"part_no": "PN-2", "source_pages": [2]}, + {"item_no": "1896-S", "source_pages": [1]}, + {"item_no": "1906-S", "source_pages": [2]}, ] ) ) diff --git a/tests/test_extract/test_schema.py b/tests/test_extract/test_schema.py index 7a4b378..95950ed 100644 --- a/tests/test_extract/test_schema.py +++ b/tests/test_extract/test_schema.py @@ -23,46 +23,46 @@ def test_load_minimal_schema(tmp_path: Path) -> None: name: "Test" prompt: "extract things" fields: - part_no: + item_no: type: string required: true - description: "the part" + description: "the item" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) schema = load_schema(path) assert schema.name == "Test" assert schema.list is False - assert schema.fields["part_no"].required is True - assert schema.output.endpoint == "trackedParts" + assert schema.fields["item_no"].required is True + assert schema.output.endpoint == "purchaseInvoiceLines" def test_load_schema_compiles_to_json_schema_with_source_pages(tmp_path: Path) -> None: path = _write_schema( tmp_path, """ -name: "8130" +name: "vendor-invoice" prompt: "..." list: true fields: - serial_no: + unit_of_measure: type: string required: true - description: "Block 13" + description: "unit of measure column" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - serialNo: serial_no + unitOfMeasureCode: unit_of_measure """, ) js = load_schema(path).to_json_schema() record_props = js["properties"]["records"]["items"]["properties"] - assert "serial_no" in record_props + assert "unit_of_measure" in record_props assert record_props["source_pages"]["type"] == "array" assert record_props["source_pages"]["items"]["type"] == "integer" assert "source_pages" in js["properties"]["records"]["items"]["required"] @@ -75,14 +75,14 @@ def test_field_map_referencing_unknown_field_rejected(tmp_path: Path) -> None: name: "bad" prompt: "..." fields: - part_no: + item_no: type: string required: true - description: "the part" + description: "the item" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - serialNo: serial_no # not declared + unitOfMeasureCode: unit_of_measure # not declared """, ) with pytest.raises(ExtractError, match="unknown extracted fields"): @@ -96,15 +96,15 @@ def test_parent_field_without_parent_param_rejected(tmp_path: Path) -> None: name: "bad parent" prompt: "..." fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts - parent_field: parentEngineId + endpoint: purchaseInvoiceLines + parent_field: documentId field_map: - partNo: part_no + lineObjectNumber: item_no """, ) with pytest.raises(ExtractError, match="parent_param missing"): @@ -119,16 +119,16 @@ def test_extra_output_keys_rejected(tmp_path: Path) -> None: name: "typo" prompt: "..." fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts - parent_filed: parentEngineId # typo: filed → field - parent_param: parent_engine_id + endpoint: purchaseInvoiceLines + parent_filed: documentId # typo: filed → field + parent_param: purchase_invoice_id field_map: - partNo: part_no + lineObjectNumber: item_no """, ) with pytest.raises(ExtractError): @@ -177,16 +177,16 @@ def test_date_field_gets_iso_hint(tmp_path: Path) -> None: name: "d" prompt: "..." fields: - cert_date: + invoice_date: type: date required: true - description: "Certification date" + description: "Invoice date" output: endpoint: e field_map: - certDate: cert_date + invoiceDate: invoice_date """, ) js = load_schema(path).to_json_schema() - desc = js["properties"]["records"]["items"]["properties"]["cert_date"]["description"] + desc = js["properties"]["records"]["items"]["properties"]["invoice_date"]["description"] assert "ISO 8601" in desc diff --git a/tests/test_extract/test_yaml_writer.py b/tests/test_extract/test_yaml_writer.py index 4a9b800..83c4fc9 100644 --- a/tests/test_extract/test_yaml_writer.py +++ b/tests/test_extract/test_yaml_writer.py @@ -22,39 +22,39 @@ def test_batch_yaml_round_trips_through_yaml_loader(tmp_path: Path) -> None: schema = _schema( tmp_path, """ -name: "8130" -prompt: "extract one record per tag" +name: "vendor-invoice" +prompt: "extract one record per invoice line" list: true fields: - part_no: + item_no: type: string required: true - description: "block 7" - serial_no: + description: "item number column" + unit_of_measure: type: string required: true - description: "block 13" + description: "unit of measure column" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines action: post - parent_field: parentEngineId - parent_param: parent_engine_id + parent_field: documentId + parent_param: purchase_invoice_id field_map: - partNo: part_no - serialNo: serial_no + lineObjectNumber: item_no + unitOfMeasureCode: unit_of_measure constants: - documentType: "8130-3" + lineType: "Item" """, ) result = ExtractionResult( - schema_name="8130", + schema_name="vendor-invoice", records=[ ExtractedRecord( - fields={"part_no": "PN-1", "serial_no": "SN-A"}, + fields={"item_no": "1896-S", "unit_of_measure": "PCS"}, source_pages=(1, 2), ), ExtractedRecord( - fields={"part_no": "PN-2", "serial_no": "SN-B"}, + fields={"item_no": "1906-S", "unit_of_measure": "BOX"}, source_pages=(3,), ), ], @@ -62,22 +62,22 @@ def test_batch_yaml_round_trips_through_yaml_loader(tmp_path: Path) -> None: ) rendered = render_batch_yaml( - result, schema, source_pdf=tmp_path / "blades.pdf" + result, schema, source_pdf=tmp_path / "invoice.pdf" ) parsed = yaml.safe_load(rendered) - assert parsed["name"].startswith("Load 8130 from") - assert "parent_engine_id" in parsed["params"] + assert parsed["name"].startswith("Load vendor-invoice from") + assert "purchase_invoice_id" in parsed["params"] assert len(parsed["steps"]) == 2 first = parsed["steps"][0] assert first["action"] == "post" - assert first["endpoint"] == "trackedParts" - assert first["data"]["partNo"] == "PN-1" - assert first["data"]["serialNo"] == "SN-A" - assert first["data"]["documentType"] == "8130-3" + assert first["endpoint"] == "purchaseInvoiceLines" + assert first["data"]["lineObjectNumber"] == "1896-S" + assert first["data"]["unitOfMeasureCode"] == "PCS" + assert first["data"]["lineType"] == "Item" # Parent linkage emitted as ${{ params.X }} reference - assert first["data"]["parentEngineId"] == "${{ params.parent_engine_id }}" + assert first["data"]["documentId"] == "${{ params.purchase_invoice_id }}" def test_batch_yaml_omits_params_when_no_parent_param(tmp_path: Path) -> None: @@ -87,19 +87,19 @@ def test_batch_yaml_omits_params_when_no_parent_param(tmp_path: Path) -> None: name: "loose" prompt: "..." fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) result = ExtractionResult( schema_name="loose", - records=[ExtractedRecord(fields={"part_no": "X"})], + records=[ExtractedRecord(fields={"item_no": "X"})], ) parsed = yaml.safe_load( render_batch_yaml(result, schema, source_pdf=tmp_path / "f.pdf") @@ -115,23 +115,23 @@ def test_sidecar_json_has_source_pages_and_warnings(tmp_path: Path) -> None: prompt: "..." list: true fields: - part_no: + item_no: type: string required: true description: "x" output: - endpoint: trackedParts + endpoint: purchaseInvoiceLines field_map: - partNo: part_no + lineObjectNumber: item_no """, ) result = ExtractionResult( schema_name="list-only", records=[ ExtractedRecord( - fields={"part_no": "PN"}, + fields={"item_no": "1896-S"}, source_pages=(7,), - raw='{"part_no": "PN"}', + raw='{"item_no": "1896-S"}', ) ], model="claude-sonnet-4-6", diff --git a/tests/test_odata/test_filter_fields.py b/tests/test_odata/test_filter_fields.py index 7f7616c..156e88a 100644 --- a/tests/test_odata/test_filter_fields.py +++ b/tests/test_odata/test_filter_fields.py @@ -11,8 +11,8 @@ class TestExtractFieldReferences: def test_simple_eq(self): - refs = extract_field_references("engineSerialNumber eq '100001'") - assert refs == ["engineSerialNumber"] + refs = extract_field_references("customerNumber eq '10000'") + assert refs == ["customerNumber"] def test_strips_string_literals(self): refs = extract_field_references("displayName eq 'has eq inside'") @@ -24,9 +24,9 @@ def test_handles_double_quotes(self): def test_compound_filter(self): refs = extract_field_references( - "engineSerialNumber eq '100001' and tailNo eq 'N12345'" + "customerNumber eq '10000' and status eq 'Open'" ) - assert refs == ["engineSerialNumber", "tailNo"] + assert refs == ["customerNumber", "status"] def test_function_calls_excluded(self): # Function name is reserved when followed by '(', but its arguments @@ -59,20 +59,18 @@ def test_close_match_exact_typo(self): assert suggest_field("displayname", ["displayName", "name"]) == ["displayName"] def test_initialism_substring(self): - # 'esn' isn't close by edit-distance, but it's a substring of - # engineSerialNumber's lowercased form (e... s... n... → "engineserialnumber"). - # The substring fallback handles this. - suggestions = suggest_field("esn", ["engineSerialNumber", "tailNo", "asOfDate"]) - # Lowercased, "esn" appears as letters 'e','s','n' but not as a contiguous - # substring of "engineserialnumber". So the substring fallback only fires - # when needle IS contiguous. Skip: this case must rely on the user typing - # something close — guard the reasonable behaviour: - # 'tailno' → 'tailNo' contiguous. + # 'cn' isn't close by edit-distance to customerNumber, and although + # its letters appear in order in "customernumber" (c... n...), they + # aren't a contiguous substring. The substring fallback only fires + # when the needle IS contiguous, so this case must rely on the user + # typing something close — guard the reasonable behaviour: + suggestions = suggest_field("cn", ["customerNumber", "status", "dueDate"]) assert isinstance(suggestions, list) def test_substring_fallback(self): - # "serial" is a contiguous substring of "engineSerialNumber". - assert suggest_field("serial", ["engineSerialNumber"]) == ["engineSerialNumber"] + # "cust" is too short for difflib to match "customerNumber", but it is + # a contiguous substring of it. + assert suggest_field("cust", ["customerNumber"]) == ["customerNumber"] def test_no_match(self): assert suggest_field("zzz", ["aaa", "bbb"]) == [] @@ -82,7 +80,7 @@ def test_empty_known(self): class TestValidateFilterFields: - KNOWN = ["engineSerialNumber", "tailNo", "asOfDate", "efh", "efc"] + KNOWN = ["customerNumber", "invoiceDate", "dueDate", "status", "remainingAmount"] def test_returns_none_when_filter_empty(self): assert validate_filter_fields(None, self.KNOWN) is None @@ -94,23 +92,23 @@ def test_returns_none_when_known_empty(self): def test_passes_when_all_known(self): assert validate_filter_fields( - "engineSerialNumber eq '100001' and tailNo eq 'N12345'", + "customerNumber eq '10000' and status eq 'Open'", self.KNOWN, ) is None def test_flags_unknown(self): - result = validate_filter_fields("esn eq '100001'", self.KNOWN) + result = validate_filter_fields("cust eq '10000'", self.KNOWN) assert result is not None msg, unknown = result - assert unknown == ["esn"] - assert "esn" in msg - # The substring fallback finds 'engineSerialNumber' (contains 'esn'). + assert unknown == ["cust"] + assert "cust" in msg + # The substring fallback finds 'customerNumber' (contains 'cust'). # Either difflib or substring should produce *some* hint here. - assert "engineSerialNumber" in msg or "Did you mean" not in msg + assert "customerNumber" in msg or "Did you mean" not in msg def test_flags_typo_with_close_match(self): - result = validate_filter_fields("tailNumber eq 'N12345'", self.KNOWN) + result = validate_filter_fields("dueDateTime eq 2026-01-31", self.KNOWN) assert result is not None msg, _ = result - # 'tailNumber' is close to 'tailNo' via difflib. - assert "tailNo" in msg + # 'dueDateTime' is close to 'dueDate' via difflib. + assert "dueDate" in msg diff --git a/tests/test_odata/test_query.py b/tests/test_odata/test_query.py index 61d7f3a..65fbfbf 100644 --- a/tests/test_odata/test_query.py +++ b/tests/test_odata/test_query.py @@ -57,9 +57,9 @@ def test_count(): def test_fluent_chaining(): q = ( Query() - .filter("engineModel eq 'CF34-10E'") - .select("esn", "status", "nbv") - .orderby("esn") + .filter("status eq 'Open'") + .select("number", "status", "totalAmountIncludingTax") + .orderby("number") .top(50) ) params = q.to_params() diff --git a/tests/test_queries/test_expand.py b/tests/test_queries/test_expand.py index d61fff0..fcad75e 100644 --- a/tests/test_queries/test_expand.py +++ b/tests/test_queries/test_expand.py @@ -10,13 +10,13 @@ def test_expand_query_resolves_param_references(): spec = { - "endpoint": "equipmentUtilizations", - "filter": "engineSerialNumber eq '${{ params.esn }}'", + "endpoint": "salesInvoices", + "filter": "customerNumber eq '${{ params.customer_no }}'", "top": 24, } - resolved = expand_query(spec, {"esn": "100001"}) - assert resolved.filter == "engineSerialNumber eq '100001'" - assert resolved.endpoint == "equipmentUtilizations" + resolved = expand_query(spec, {"customer_no": "10000"}) + assert resolved.filter == "customerNumber eq '10000'" + assert resolved.endpoint == "salesInvoices" assert resolved.top == 24 @@ -40,9 +40,9 @@ def test_expand_query_ignores_non_odata_metadata_fields(): "endpoint": "x", "description": "irrelevant to expansion", "tags": ["a", "b"], - "params": {"esn": {"required": True}}, + "params": {"customer_no": {"required": True}}, } - resolved = expand_query(spec, {"esn": "1"}) + resolved = expand_query(spec, {"customer_no": "1"}) assert not hasattr(resolved, "description") assert not hasattr(resolved, "tags") @@ -58,11 +58,11 @@ def test_expand_query_escapes_single_quote_in_filter(): def test_expand_query_neutralises_injection_in_filter(): spec = { - "endpoint": "equipmentUtilizations", - "filter": "engineSerialNumber eq '${{ params.esn }}'", + "endpoint": "salesInvoices", + "filter": "customerNumber eq '${{ params.customer_no }}'", } - resolved = expand_query(spec, {"esn": "100001' or 1 eq 1--"}) - assert resolved.filter == "engineSerialNumber eq '100001'' or 1 eq 1--'" + resolved = expand_query(spec, {"customer_no": "10000' or 1 eq 1--"}) + assert resolved.filter == "customerNumber eq '10000'' or 1 eq 1--'" assert resolved.filter.count("'") % 2 == 0 diff --git a/tests/test_queries/test_params.py b/tests/test_queries/test_params.py index c54ad6b..baf08fa 100644 --- a/tests/test_queries/test_params.py +++ b/tests/test_queries/test_params.py @@ -25,17 +25,17 @@ def test_resolve_params_supplied_overrides_default(): def test_resolve_params_required_missing_raises(): - declared = {"esn": {"required": True}} + declared = {"customer_no": {"required": True}} with pytest.raises(QueryParamError) as exc_info: resolve_params(declared, {}) - assert exc_info.value.key == "esn" + assert exc_info.value.key == "customer_no" assert exc_info.value.kind == "missing_required" - assert "Missing required parameter 'esn'" in str(exc_info.value) + assert "Missing required parameter 'customer_no'" in str(exc_info.value) def test_resolve_params_required_supplied(): - declared = {"esn": {"required": True}} - assert resolve_params(declared, {"esn": 100001}) == {"esn": 100001} + declared = {"customer_no": {"required": True}} + assert resolve_params(declared, {"customer_no": 10000}) == {"customer_no": 10000} def test_resolve_params_none_declared_passes_supplied_through_unvalidated(): @@ -49,9 +49,9 @@ def test_resolve_params_none_declared_and_none_supplied_is_empty(): def test_resolve_params_unknown_supplied_keys_pass_through(): """Params not in `declared` aren't validated but do end up in the result.""" - declared = {"esn": {"required": True}} - resolved = resolve_params(declared, {"esn": "1", "extra": "kept"}) - assert resolved == {"esn": "1", "extra": "kept"} + declared = {"customer_no": {"required": True}} + resolved = resolve_params(declared, {"customer_no": "1", "extra": "kept"}) + assert resolved == {"customer_no": "1", "extra": "kept"} # ── validate_param via resolve_params (type/pattern/min/max/enum) ──────── @@ -84,18 +84,18 @@ def test_integer_within_bounds_accepted(self): assert resolve_params(declared, {"limit": 50}) == {"limit": 50} def test_string_pattern_accepts_match(self): - declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} - assert resolve_params(declared, {"airline": "ACMEAIR"}) == {"airline": "ACMEAIR"} + declared = {"currency": {"required": True, "type": "string", "pattern": r"^[A-Z]{3}$"}} + assert resolve_params(declared, {"currency": "USD"}) == {"currency": "USD"} def test_string_pattern_rejects_non_match(self): - declared = {"airline": {"required": True, "type": "string", "pattern": r"^[A-Z0-9]{2,8}$"}} + declared = {"currency": {"required": True, "type": "string", "pattern": r"^[A-Z]{3}$"}} with pytest.raises(QueryParamError): - resolve_params(declared, {"airline": "little caesars"}) + resolve_params(declared, {"currency": "little caesars"}) def test_string_pattern_rejects_injection_attempt(self): - declared = {"esn": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} + declared = {"customer_no": {"required": True, "type": "string", "pattern": r"^\d{4,8}$"}} with pytest.raises(QueryParamError): - resolve_params(declared, {"esn": "100001' or 1 eq 1--"}) + resolve_params(declared, {"customer_no": "10000' or 1 eq 1--"}) def test_enum_accepts_valid(self): declared = {"status": {"required": True, "enum": ["Open", "Posted"]}} diff --git a/tests/test_registry/test_importers.py b/tests/test_registry/test_importers.py index d56e119..f2b6e80 100644 --- a/tests/test_registry/test_importers.py +++ b/tests/test_registry/test_importers.py @@ -23,14 +23,14 @@ def test_import_from_postman_synthetic_collection(): groups = {f"{e.api_publisher}/{e.api_group}/{e.api_version}" for e in endpoints} assert "acme/finance/v1.0" in groups assert "acme/standard/v1.0" in groups - assert "acme/technical/v1.0" in groups + assert "acme/integration/v1.0" in groups # Specific entities parsed correctly names = {e.entity_set_name for e in endpoints} assert "glAccounts" in names assert "customers" in names assert "vendors" in names - assert "equipmentRecords" in names + assert "shipmentTrackings" in names # Methods collected (GL entries endpoint has POST) gl_entries = [e for e in endpoints if e.entity_set_name == "glEntries"] diff --git a/tests/test_registry/test_metadata_fields.py b/tests/test_registry/test_metadata_fields.py index 33901c8..ec51f37 100644 --- a/tests/test_registry/test_metadata_fields.py +++ b/tests/test_registry/test_metadata_fields.py @@ -12,22 +12,22 @@ - + - - - - - + + + + + - + - + - - + + @@ -37,35 +37,35 @@ def test_parses_entity_type_properties(): fields = _parse_entity_type_properties(SAMPLE_EDMX) - assert fields["equipmentUtilization"] == [ + assert fields["shipmentTracking"] == [ "systemId", - "engineSerialNumber", - "tailNo", - "asOfDate", - "efh", - "efc", + "shipmentNumber", + "trackingNumber", + "shipmentDate", + "weight", + "packageCount", ] - assert fields["equipmentOverview"] == ["systemId", "engineModel"] + assert fields["shipmentCarrier"] == ["systemId", "carrier"] def test_parse_metadata_xml_attaches_field_names(): endpoints = _parse_metadata_xml( - SAMPLE_EDMX, publisher="contoso", group="technical", version="v1.5" + SAMPLE_EDMX, publisher="contoso", group="integration", version="v1.5" ) by_name = {ep.entity_set_name: ep for ep in endpoints} - assert "equipmentUtilizations" in by_name - eu = by_name["equipmentUtilizations"] - assert eu.api_publisher == "contoso" - assert eu.api_group == "technical" - assert eu.api_version == "v1.5" - assert eu.entity_name == "equipmentUtilization" - assert "engineSerialNumber" in eu.field_names - assert "tailNo" in eu.field_names - assert "efh" in eu.field_names + assert "shipmentTrackings" in by_name + st = by_name["shipmentTrackings"] + assert st.api_publisher == "contoso" + assert st.api_group == "integration" + assert st.api_version == "v1.5" + assert st.entity_name == "shipmentTracking" + assert "shipmentNumber" in st.field_names + assert "trackingNumber" in st.field_names + assert "weight" in st.field_names - eo = by_name["equipmentOverviews"] - assert eo.field_names == ["systemId", "engineModel"] + sc = by_name["shipmentCarriers"] + assert sc.field_names == ["systemId", "carrier"] def test_parse_metadata_xml_handles_missing_properties(): diff --git a/tests/test_registry/test_registry.py b/tests/test_registry/test_registry.py index 0ecfab8..84cd3ee 100644 --- a/tests/test_registry/test_registry.py +++ b/tests/test_registry/test_registry.py @@ -61,9 +61,9 @@ def test_endpoint_metadata_is_custom(): assert not standard.is_custom custom = EndpointMetadata( - entity_set_name="equipmentOverviews", + entity_set_name="shipmentTrackings", api_publisher="contoso", - api_group="technical", + api_group="integration", api_version="v1.5", ) assert custom.is_custom diff --git a/tests/test_skill_init/test_skill_init_wizard.py b/tests/test_skill_init/test_skill_init_wizard.py index 71c3192..9ad9eb6 100644 --- a/tests/test_skill_init/test_skill_init_wizard.py +++ b/tests/test_skill_init/test_skill_init_wizard.py @@ -547,7 +547,7 @@ def test_default_proposer_empty_for_any_role( InterviewState, _default_role_template_proposer, ) - for role in ("ops", "aviation", "sales", "dev", "custom"): + for role in ("ops", "purchasing", "sales", "dev", "custom"): interview = InterviewState( role=role, top_three="x", style="flat", generate_new=True, ) diff --git a/tests/test_skill_install/test_skill_install.py b/tests/test_skill_install/test_skill_install.py index 268dfa6..4cd103a 100644 --- a/tests/test_skill_install/test_skill_install.py +++ b/tests/test_skill_install/test_skill_install.py @@ -118,16 +118,16 @@ def _invoke(*args: str): def test_skill_install_creates_command_per_saved_query(isolated_home, cli_state): _write_queries(isolated_home, """ queries: - utilization-by-esn: - description: Engine utilization for an ESN - categories: [aviation, daily-ops] + invoices-by-customer: + description: Sales invoices for a customer + categories: [sales, daily-ops] args: - - name: esn + - name: customer_no type: string - example: "424322" + example: "10000" required: true - endpoint: util_history - filter: "engine_serial eq '${{ args.esn }}'" + endpoint: salesInvoices + filter: "customerNumber eq '${{ args.customer_no }}'" customer-by-name: description: Look up a customer by display name @@ -146,18 +146,18 @@ def test_skill_install_creates_command_per_saved_query(isolated_home, cli_state) cmds_dir = target / ".claude" / "commands" assert cmds_dir.is_dir() - util = cmds_dir / "bcli-utilization-by-esn.md" + invoices = cmds_dir / "bcli-invoices-by-customer.md" cust = cmds_dir / "bcli-customer-by-name.md" - assert util.is_file() + assert invoices.is_file() assert cust.is_file() - util_text = util.read_text(encoding="utf-8") + invoices_text = invoices.read_text(encoding="utf-8") # Frontmatter has a description. - assert "description: Engine utilization for an ESN" in util_text + assert "description: Sales invoices for a customer" in invoices_text # The body invokes ``bcli q ...`` with the args threaded positionally. - assert "bcli q utilization-by-esn esn=$1" in util_text + assert "bcli q invoices-by-customer customer_no=$1" in invoices_text # The body references --format json (agent-friendly). - assert "--format json" in util_text + assert "--format json" in invoices_text # customer-by-name has no explicit args list — installer derives one # from the params keys. @@ -216,28 +216,28 @@ def test_skill_install_renders_argument_hint_in_frontmatter(isolated_home, cli_s def test_skill_install_creates_command_per_batch_yaml(isolated_home, cli_state): - _write_batch(isolated_home, "engine-360", """ - name: engine-360 - description: Full engine 360 for a given ESN - categories: [aviation] + _write_batch(isolated_home, "customer-360", """ + name: customer-360 + description: Full customer 360 for a given customer number + categories: [sales] params: - esn: + customer_no: required: true steps: - action: get - endpoint: engines + endpoint: customers params: - filter: "serial eq '${{ params.esn }}'" + filter: "number eq '${{ params.customer_no }}'" """) target = isolated_home / "proj" target.mkdir() _invoke("--target", str(target)) - md = (target / ".claude" / "commands" / "bcli-batch-engine-360.md") + md = (target / ".claude" / "commands" / "bcli-batch-customer-360.md") assert md.is_file() text = md.read_text(encoding="utf-8") # Body invokes bcli batch run with --format json + --result-out. assert "bcli batch run" in text - assert "engine-360" in text + assert "customer-360" in text assert "--format json" in text assert "--result-out" in text @@ -250,13 +250,13 @@ def test_skill_install_generates_index_grouped_by_categories( ): _write_queries(isolated_home, """ queries: - util-a: - description: Aviation util A - categories: [aviation] + sales-a: + description: Sales report A + categories: [sales] endpoint: a - util-b: - description: Aviation util B - categories: [aviation] + sales-b: + description: Sales report B + categories: [sales] endpoint: b customer-by-name: description: Look up a customer @@ -278,12 +278,12 @@ def test_skill_install_generates_index_grouped_by_categories( # Each category is a section header. Headings are sorted # alphabetically for deterministic output; ``unsorted`` (the # fallback bucket for the ``orphan`` query) sorts to the end. - assert "## aviation" in body assert "## finance" in body + assert "## sales" in body assert "## unsorted" in body # Each command listed under its category. - assert "bcli-util-a" in body - assert "bcli-util-b" in body + assert "bcli-sales-a" in body + assert "bcli-sales-b" in body assert "bcli-customer-by-name" in body assert "bcli-orphan" in body diff --git a/tests/test_url/test_url_builder.py b/tests/test_url/test_url_builder.py index cc2e1ee..a6e117d 100644 --- a/tests/test_url/test_url_builder.py +++ b/tests/test_url/test_url_builder.py @@ -21,14 +21,14 @@ def test_custom_api_url(): url = build_url( environment="SBEnvOct25T", company_id="abc-123", - entity_set_name="equipmentOverviews", + entity_set_name="shipmentTrackings", publisher="contoso", - group="technical", + group="integration", version="v1.5", ) - assert "api/contoso/technical/v1.5" in url + assert "api/contoso/integration/v1.5" in url assert "SBEnvOct25T" in url - assert url.endswith("/equipmentOverviews") + assert url.endswith("/shipmentTrackings") def test_url_with_record_id(): @@ -53,9 +53,9 @@ def test_custom_api_validation_path_traversal_dot_dot(): build_url( environment="Production", company_id="abc-123", - entity_set_name="equipmentOverviews", + entity_set_name="shipmentTrackings", publisher="contoso", - group="technical", + group="integration", version="..", ) @@ -66,9 +66,9 @@ def test_custom_api_validation_slash_in_segment(): build_url( environment="Production", company_id="abc-123", - entity_set_name="equipmentOverviews", + entity_set_name="shipmentTrackings", publisher="a/b", - group="technical", + group="integration", version="v1.5", ) diff --git a/tests/test_workflow/test_loader.py b/tests/test_workflow/test_loader.py index b8c85d6..aadd585 100644 --- a/tests/test_workflow/test_loader.py +++ b/tests/test_workflow/test_loader.py @@ -24,7 +24,7 @@ def test_bare_no_key_rejected(self) -> None: - action: post endpoint: purchaseLines data: - no: "6260-000000-000" + no: "8430" """ with pytest.raises(WorkflowError, match="boolean False"): load_workflow_yaml(yaml_text) @@ -43,10 +43,10 @@ def test_quoted_no_key_accepted(self) -> None: - action: post endpoint: purchaseLines data: - "no": "6260-000000-000" + "no": "8430" """ result = load_workflow_yaml(yaml_text) - assert result["steps"][0]["data"]["no"] == "6260-000000-000" + assert result["steps"][0]["data"]["no"] == "8430" def test_no_inside_string_value_accepted(self) -> None: """Templates referencing ``no`` field work — the bool trap @@ -100,11 +100,12 @@ def test_load_real_example(self) -> None: repo_root = Path(__file__).resolve().parents[2] example = repo_root / "examples" / "create-purchase-invoice.yaml" result = load_workflow_yaml(example) - # The fix: "no" is a string key with the GL account post_lines = [ step for step in result["steps"] - if step.get("endpoint") == "purchaseLines" + if step.get("endpoint") == "purchaseInvoiceLines" ] - assert post_lines, "example should have purchaseLines steps" - assert post_lines[0]["data"]["no"] == "6260-000000-000" + assert post_lines, "example should have purchaseInvoiceLines steps" + assert post_lines[0]["data"]["lineType"] == "Account" + for step in result["steps"]: + assert all(isinstance(k, str) for k in step.get("data", {})) diff --git a/tests/test_workflow/test_query_search.py b/tests/test_workflow/test_query_search.py index 99853a2..3fff171 100644 --- a/tests/test_workflow/test_query_search.py +++ b/tests/test_workflow/test_query_search.py @@ -29,12 +29,12 @@ "freshness": "live", "endpoint": "purchaseOrders", }, - "engine-by-esn": { - "description": "Engine record by serial number", - "tags": ["engine", "ops"], - "owner": "technical", + "customer-by-no": { + "description": "Customer record by customer number", + "tags": ["customer", "sales"], + "owner": "sales-ops", "freshness": "live", - "endpoint": "enginesView", + "endpoint": "customers", }, } @@ -50,7 +50,7 @@ def test_normalize_handles_missing_metadata(): def test_normalize_sorts_alphabetically(): entries = normalize_queries(_SAMPLE) - assert [e.name for e in entries] == ["engine-by-esn", "open-pos", "overdue-ic"] + assert [e.name for e in entries] == ["customer-by-no", "open-pos", "overdue-ic"] # ─── filter_entries ─────────────────────────────────────────────────── @@ -64,8 +64,8 @@ def test_filter_by_tag(): def test_filter_by_owner(): entries = normalize_queries(_SAMPLE) - out = filter_entries(entries, owner="technical") - assert [e.name for e in out] == ["engine-by-esn"] + out = filter_entries(entries, owner="sales-ops") + assert [e.name for e in out] == ["customer-by-no"] def test_filter_combined(): @@ -110,8 +110,8 @@ def test_search_floor_drops_unrelated(): def test_search_partial_phrase_finds_query(): entries = normalize_queries(_SAMPLE) - hits = search_entries(entries, "engine serial") - assert any(e.name == "engine-by-esn" for _, e in hits) + hits = search_entries(entries, "customer number") + assert any(e.name == "customer-by-no" for _, e in hits) def test_search_ranks_name_match_above_tag_match(): From 06d37d96bc24c040383725479501626401c8f100 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 23:20:24 +0000 Subject: [PATCH 5/5] feat(registry): make adding and sharing custom APIs portable - registry import --from-file: JSON/YAML registry files or Postman collections; top-level publisher/group/version with bare entity names - registry import --from-metadata takes --publisher/--group/--version - imports merge instead of overwriting (--replace to start over; pack entries are kept) - registry export writes a portable file; pack presets accept it - entries with a partial custom route are rejected - short publisher/group/version keys accepted in registry entries - fix invalid '--from-metadata ' hint - rewrite docs/custom-apis.md and add examples/custom-apis.yaml Co-authored-by: Igor --- AGENTS.md | 22 ++- CHANGELOG.md | 29 ++++ README.md | 6 +- docs/command-reference.md | 17 +- docs/custom-apis.md | 201 ++++++++++------------ docs/getting-started.md | 6 +- examples/custom-apis.yaml | 28 +++ src/bcli/diagnostics/_checks.py | 6 +- src/bcli/packs/_loader.py | 19 ++- src/bcli/registry/__init__.py | 2 + src/bcli/registry/_importers.py | 146 ++++++++++++++-- src/bcli/registry/_schema.py | 21 ++- src/bcli_cli/_error_handler.py | 4 +- src/bcli_cli/commands/config_cmd.py | 31 ++-- src/bcli_cli/commands/etl_cmd.py | 4 +- src/bcli_cli/commands/registry_cmd.py | 217 +++++++++++++++--------- tests/test_packs/test_loader.py | 36 ++++ tests/test_registry/test_import_file.py | 211 +++++++++++++++++++++++ 18 files changed, 746 insertions(+), 260 deletions(-) create mode 100644 examples/custom-apis.yaml create mode 100644 tests/test_registry/test_import_file.py diff --git a/AGENTS.md b/AGENTS.md index 1a5e14d..03444eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,9 @@ you. You do **not** need `--publisher … --group … --version …` — those are escape hatches for the rare case where an admin hasn't imported the endpoint yet, and they're hidden from `--help` for that reason. If `bcli get ` errors with `RegistryError`, the fix is to import the -endpoint into the registry, not to pass override flags. +endpoint into the registry (`bcli registry import --from-metadata +--publisher P --group G --version V`, see +[`docs/custom-apis.md`](docs/custom-apis.md)), not to pass override flags. --- @@ -47,17 +49,21 @@ redundant flags that the profile + registry already supply. ```bash # ❌ Don't write this: -bcli -c LLC get fixedAssets --publisher contoso --group finance --version v1.5 --all -f json +bcli -c CRONUS get fixedAssets --publisher contoso --group finance --version v1.5 --all -f json # ✅ Write this: -bcli -c LLC get fixedAssets +bcli -c CRONUS get fixedAssets ``` Why each flag was wrong: -- `--publisher contoso --group finance --version v1.5` — the - registry resolves these automatically. Only pass them if the - endpoint isn't in the registry (and even then, prefer importing it). +- `--publisher contoso --group finance --version v1.5` — redundant at + best and wrong at worst. `fixedAssets` is a standard v2.0 entity, so + these flags point the request at a custom route that doesn't serve + it. For custom endpoints already in the registry, the registry + resolves publisher / group / version automatically. Only pass them + if the endpoint isn't in the registry (and even then, prefer + importing it). - `--all` — pulls **every** page. Most asks need `--top 5` or no pagination flag at all. Use `--all` only when the user explicitly asks for a full export. @@ -76,7 +82,7 @@ when the user's question demands them. 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. `inspectionStatuses`, not `inspectionStatus`). +obvious-singular (e.g. `agedAccountsPayables`, not `agedAccountsPayable`). The recipe: ```bash @@ -99,7 +105,7 @@ 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 +expect (`vendNo` rather than `vendorNumber`, `no` rather than `number`). Don't pass `--filter " eq 'X'"` and hope for the best — that's 1–2 wasted tool calls per guess. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2021bc..0d722b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `bcli registry import --from-file` reads a registry file in JSON or YAML, or + a Postman collection. A file can set `publisher` / `group` / `version` once + at the top and list endpoints by bare entity-set name. See + `examples/custom-apis.yaml`. +- `bcli registry import --from-metadata` takes `--publisher`, `--group` and + `--version` directly instead of requiring them in the profile. +- `bcli registry export` writes a profile's custom endpoints to a portable + file for teammates, packs, or team bundles. Pack registry presets accept the + same format. +- Registry entries accept the short keys `publisher` / `group` / `version` + alongside `api_publisher` / `api_group` / `api_version`. + +### Changed + +- `bcli registry import` merges with the existing registry instead of + overwriting it, so importing a second API route no longer drops the first + and no longer deletes pack-installed endpoints. Pass `--replace` for the old + behaviour (pack-installed endpoints are still kept). +- A registry entry that names only part of a custom route is rejected instead + of silently routing to the standard v2.0 API. +- `--from-postman` and `--from-json` still work but are hidden in favour of + `--from-file`. + ### Fixed +- The "endpoint not registered" hint suggested + `bcli registry import --from-metadata `, which is not valid + syntax. + - Interactive auth no longer prompts roughly once an hour. `BrowserAuth` and `DeviceCodeAuth` both built `msal.PublicClientApplication` without a `token_cache=`, so MSAL's cache existed only in memory. In a fresh process — diff --git a/README.md b/README.md index b8f434b..dde7bbf 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ bcli get customers --top 5 bcli get vendors --filter "displayName eq 'Fabrikam'" --format json bcli get salesInvoices --select number,totalAmountIncludingTax --top 10 -# Import custom APIs from a Postman collection -bcli registry import --from-postman ./my_collection.json +# Register your own AL API pages straight from BC +bcli registry import --from-metadata --publisher contoso --group integration --version v1.0 # Query custom endpoints (route auto-resolved) bcli get myCustomEntities --top 5 @@ -70,7 +70,7 @@ bcli get myCustomEntities --top 5 ## Features - **Works out of the box** — 79 standard BC v2.0 entities (customers, vendors, items, GL entries, ...) with zero configuration beyond auth -- **Custom API support** — Import your custom API pages from Postman collections, JSON, or live `$metadata` +- **Custom API support** — Register your own AL API pages from live `$metadata`, a short YAML/JSON file, or a Postman collection, and share them with `bcli registry export` - **Three-tier endpoint resolution** — Custom registry -> standard v2.0 -> fuzzy suggestions - **Multi-company** — Assign aliases to companies and query across all entities - **OData query builder** — `--filter`, `--select`, `--expand`, `--orderby`, `--top`, `--skip` on every query diff --git a/docs/command-reference.md b/docs/command-reference.md index 8aa1e67..60b871f 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -223,12 +223,21 @@ bcli endpoint info ### registry import -Import custom API endpoints. +Add custom API endpoints to a profile's registry. Imports merge with what is +already registered; pass `--replace` to start over. See [Custom APIs](custom-apis.md). ```bash -bcli registry import --from-postman [--profile ] -bcli registry import --from-json [--profile ] -bcli registry import --from-metadata [--profile ] +bcli registry import --from-metadata --publisher

--group --version [--profile ] +bcli registry import --from-file [--profile ] +``` + +### registry export + +Write a profile's custom endpoints to a portable file that others can import +with `--from-file`. + +```bash +bcli registry export [-o ] [--profile ] ``` ### registry list diff --git a/docs/custom-apis.md b/docs/custom-apis.md index 23f966b..aaf8798 100644 --- a/docs/custom-apis.md +++ b/docs/custom-apis.md @@ -1,155 +1,130 @@ # Custom APIs -bcli ships with 79 standard Microsoft BC v2.0 entities. If you have custom API pages built in AL, you can import them so bcli auto-resolves their routes. - -## Import from Postman Collection - -If you have a Postman collection for your custom APIs: +bcli ships with the 79 standard Microsoft Business Central API v2.0 entities +(`customers`, `vendors`, `salesInvoices`, ...). If your extension publishes its +own API pages in AL, register them once and they work exactly like the +standard ones: ```bash -bcli registry import --from-postman ./my_collection.json +bcli get shipmentTrackings --top 5 +bcli endpoint fields shipmentTrackings ``` -The importer parses each request's URL to extract the API publisher, group, version, and entity set name. It also reads folder descriptions for source table metadata. - -**Example output:** -``` -✓ Imported 92 custom endpoints - contoso/finance/v1.5: 46 endpoints - contoso/standard/v1.0: 23 endpoints - contoso/technical/v1.5: 23 endpoints -``` +You never type the publisher, group, or version again. The registry remembers +the route. -### Postman URL Format +## What bcli needs to know -The importer expects URLs following BC's custom API pattern: +A custom API page is reached at: ``` -https://api.businesscentral.dynamics.com/v2.0/{env}/api/{publisher}/{group}/{version}/companies({id})/{entity} +/api/{APIPublisher}/{APIGroup}/{APIVersion}/companies({id})/{EntitySetName} ``` -## Import from JSON - -If you have endpoint metadata in JSON format: +Those four values come straight from your AL page: -```bash -bcli registry import --from-json ./endpoints.json -``` - -### Supported JSON Formats - -**bcli native format:** -```json +```al +page 50100 "Shipment Tracking API" { - "endpoints": [ - { - "entity_set_name": "equipmentOverviews", - "entity_name": "equipmentOverview", - "api_publisher": "mycompany", - "api_group": "technical", - "api_version": "v1.5", - "description": "Equipment overview data", - "supports": ["GET"], - "key_field": "systemId" - } - ] + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'integration'; + APIVersion = 'v1.0'; + EntityName = 'shipmentTracking'; + EntitySetName = 'shipmentTrackings'; + ... } ``` -**Grouped format (by API group):** -```json -{ - "finance": [ - { - "entity_set_name": "vendors", - "entity_name": "vendor", - "api_publisher": "mycompany", - "api_group": "finance", - "api_version": "v1.5", - "data_access_intent": "ReadOnly", - "source_table": "Vendor" - } - ], - "technical": [...] -} -``` - -## Import from $metadata (Live Discovery) +## Option 1: discover from BC (recommended) -Query your BC environment's OData `$metadata` endpoint to discover custom APIs automatically: +If you can sign in to the environment, let bcli read the route's `$metadata` +and register every entity set it publishes: ```bash -bcli registry import --from-metadata +bcli registry import --from-metadata --publisher contoso --group integration --version v1.0 ``` -This requires `api_publisher`, `api_group`, and `api_version` to be set in your profile: - -```bash -bcli config set profiles.default.api_publisher mycompany -bcli config set profiles.default.api_group integration -bcli config set profiles.default.api_version v1.0 -bcli registry import --from-metadata +This also records each entity's field names, so `--filter` typos get +"did you mean" suggestions. Run it once per route. Imports merge, so a +second route is added next to the first rather than replacing it. + +## Option 2: write a short registry file + +When you can't reach the environment (CI, a teammate without access yet), or +you only want a few endpoints, list them in YAML or JSON. See +[`examples/custom-apis.yaml`](../examples/custom-apis.yaml): + +```yaml +publisher: contoso +group: integration +version: v1.0 +endpoints: + - shipmentTrackings + - entity_set_name: carrierRates + description: Carrier rate cards + supports: [GET, POST, PATCH, DELETE] ``` -## Registry Management - -### List Imported Registries - -```bash -bcli registry list -# production: 92 endpoints (source: postman, imported: 2026-04-12T...) -# sandbox: 93 endpoints (source: json, imported: 2026-04-12T...) -``` - -### Per-Profile Registries - -Registries are scoped to profiles. Import for a specific profile: - ```bash -bcli registry import --from-postman ./collection.json --profile production -bcli registry import --from-postman ./collection.json --profile sandbox +bcli registry import --from-file custom-apis.yaml ``` -Registries are stored at `~/.config/bcli/registries/.json`. +The top-level `publisher` / `group` / `version` apply to every entry; an entry +can override any of them. The long spellings `api_publisher` / `api_group` / +`api_version` are accepted too. An entry that names only part of a route is +rejected with an error instead of silently routing to the standard API. -## How Route Resolution Works +Optional per-entry keys: -When you run `bcli get someEntity`: +| Key | Default | Purpose | +|---|---|---| +| `description` | `""` | Shown by `bcli endpoint search` / `info` | +| `supports` | `[GET]` | HTTP methods the page allows | +| `category` | the group | Used by `bcli endpoint list --category` and scoped profiles | +| `entity_name` | singular of the set name | Informational | +| `caution` | inferred | `low` / `medium` / `high`; names containing verbs like `post`, `release`, `void` default to `high` | +| `field_names` | `[]` | Known fields for filter validation (learned automatically by `bcli endpoint fields`) | -1. **Custom registry** — Checks `~/.config/bcli/registries/.json` first. If found, uses the entity's `api_publisher/api_group/api_version` to build the URL. -2. **Standard v2.0** — Falls back to the built-in standard registry. Routes to `/api/v2.0/`. -3. **Not found** — Shows an error with fuzzy search suggestions and hints to import a registry. +`--from-file` also accepts a Postman v2.1 collection. Every request URL that +follows the custom API pattern above becomes an endpoint. -This means `bcli get customers` (standard) and `bcli get equipmentOverviews` (custom) work the same way — you never construct URLs. +## Share your registry -## Discover Endpoints +Export what a profile knows and commit it next to your AL extension, or hand +it to a teammate: ```bash -# List all endpoints (standard + custom) -bcli endpoint list +bcli registry export -o custom-apis.json +# on another machine +bcli registry import --from-file custom-apis.json +``` -# Only custom endpoints -bcli endpoint list --custom +For a team, the same file can ship as a registry preset inside a +[pack](../packs/), or inside a signed team bundle +(`bcli config refresh`), so new users get your endpoints on install. -# Only standard v2.0 -bcli endpoint list --standard +## Manage registries -# Filter by category -bcli endpoint list --category sales +```bash +bcli registry list # which profiles have custom endpoints +bcli endpoint list --custom # just your endpoints +bcli endpoint info shipmentTrackings # route, methods, caution, known fields +bcli test endpoint shipmentTrackings # fetch one record to confirm access +``` -# Search by name or description -bcli endpoint search engine +Registries are per profile and live at `~/.config/bcli/registries/.json`. +Pass `--profile` to import into a profile other than the active one. Pass +`--replace` to drop previously imported endpoints instead of merging. +Endpoints installed by a pack are kept, because the pack still owns them. -# Full details for one endpoint -bcli endpoint info equipmentOverviews -``` +## How route resolution works -## Test a Custom Endpoint +When you run `bcli get `: -After importing, verify an endpoint works: +1. **Custom registry.** If `` is registered for the profile, its route is used. +2. **Standard v2.0.** Otherwise the built-in standard registry routes to `/api/v2.0/`. +3. **Not found.** bcli suggests close matches and tells you how to register the endpoint. -```bash -bcli test endpoint equipmentOverviews -# ✓ equipmentOverviews: returned 1 record(s) -# Fields: systemId, esn, engineModel, status, ... -``` +A custom entry with the same name as a standard entity wins, which lets you +point `customers` at your own extended API page if you have one. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9eb586b..b50899d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,11 +87,11 @@ bcli endpoint info customers bcli endpoint fields customers ``` -For custom APIs, import the registry first: +For your own custom API pages, register them once (see [Custom APIs](custom-apis.md)): ```bash -bcli registry import --from-postman ./my_collection.json -bcli get myCustomEntities --top 5 +bcli registry import --from-metadata --publisher contoso --group integration --version v1.0 +bcli get shipmentTrackings --top 5 ``` ## Test Your Connection diff --git a/examples/custom-apis.yaml b/examples/custom-apis.yaml new file mode 100644 index 0000000..c413668 --- /dev/null +++ b/examples/custom-apis.yaml @@ -0,0 +1,28 @@ +# Registry file for your own AL API pages. +# +# Load it with: +# bcli registry import --from-file examples/custom-apis.yaml +# +# publisher / group / version are the APIPublisher / APIGroup / APIVersion +# properties of your API pages; entity set names are their EntitySetName. +# Set the route once here, or per entry to mix routes in one file. + +publisher: contoso +group: integration +version: v1.0 + +endpoints: + # Just the entity set name is enough. + - shipmentTrackings + + # Optional details: description (shown by `bcli endpoint search`), + # supports (HTTP methods the page allows; default GET), category. + - entity_set_name: carrierRates + description: Carrier rate cards + supports: [GET, POST, PATCH, DELETE] + + # An endpoint on a different route in the same file. + - entity_set_name: budgetLines + group: finance + version: v2.0 + description: Budget lines diff --git a/src/bcli/diagnostics/_checks.py b/src/bcli/diagnostics/_checks.py index 46821e3..313ede9 100644 --- a/src/bcli/diagnostics/_checks.py +++ b/src/bcli/diagnostics/_checks.py @@ -147,7 +147,7 @@ def check_auth_mode(ctx: CheckContext) -> CheckResult: "auth", CheckStatus.FAIL, "auth_method missing", - hint="set `auth_method = \"device_code\"` for finance/technical profiles", + hint="set `auth_method` to \"browser\", \"device_code\" or \"client_credentials\"", ) return CheckResult( "auth", @@ -284,8 +284,8 @@ def check_registry(ctx: CheckContext) -> CheckResult: CheckStatus.FAIL, "scoped profile has zero custom endpoints", hint=( - f"import a registry: `bcli registry import --from-json" - f" --profile {ctx.profile_name}`" + f"import a registry: `bcli registry import --from-file" + f" --profile {ctx.profile_name}`" ), ) if total == 0: diff --git a/src/bcli/packs/_loader.py b/src/bcli/packs/_loader.py index dc74492..883f446 100644 --- a/src/bcli/packs/_loader.py +++ b/src/bcli/packs/_loader.py @@ -32,6 +32,7 @@ TARGET_AGENTS, VALID_TARGETS, ) +from bcli.registry._importers import parse_endpoint_list class PackLoadError(Exception): @@ -261,11 +262,21 @@ def _load_presets(spec: Any, *, root: Path) -> list[PackRegistryPreset]: raise PackLoadError( f"{root}: registry preset {file!r}: expected JSON object" ) - # A preset file holds one or more endpoints. Support both - # forms: {"endpoints": {name: body}} and a single endpoint as - # the top-level object. + # A preset file holds one or more endpoints. Supported forms: + # {"endpoints": {name: body}}, the `bcli registry export` list + # form {"endpoints": [...]}, and a single endpoint as the + # top-level object. endpoints = data.get("endpoints") - if isinstance(endpoints, dict): + if isinstance(endpoints, list): + try: + parsed = parse_endpoint_list(data) + except ValueError as e: + raise PackLoadError(f"{root}: registry preset {file!r}: {e}") from e + for ep in parsed: + out.append(PackRegistryPreset( + name=ep.entity_set_name, body=ep.model_dump(exclude_none=True), + )) + elif isinstance(endpoints, dict): for name, body in endpoints.items(): if not isinstance(body, dict): raise PackLoadError( diff --git a/src/bcli/registry/__init__.py b/src/bcli/registry/__init__.py index 8108009..e283301 100644 --- a/src/bcli/registry/__init__.py +++ b/src/bcli/registry/__init__.py @@ -1,6 +1,7 @@ """Endpoint registry for route resolution.""" from bcli.registry._importers import ( + import_from_file, import_from_json, import_from_metadata, import_from_postman, @@ -11,6 +12,7 @@ __all__ = [ "EndpointMetadata", "EndpointRegistry", + "import_from_file", "import_from_json", "import_from_metadata", "import_from_postman", diff --git a/src/bcli/registry/_importers.py b/src/bcli/registry/_importers.py index 879274a..af29f95 100644 --- a/src/bcli/registry/_importers.py +++ b/src/bcli/registry/_importers.py @@ -1,4 +1,4 @@ -"""Import endpoints from Postman collections, JSON files, and $metadata.""" +"""Import endpoints from registry files (JSON/YAML), Postman collections, and $metadata.""" from __future__ import annotations @@ -7,6 +7,8 @@ from datetime import datetime, timezone from pathlib import Path +import yaml + from bcli.config._defaults import REGISTRIES_DIR from bcli.registry._schema import CautionLevel, EndpointMetadata @@ -183,25 +185,88 @@ def _parse_path_segments(segments: list[str]) -> tuple[str, str, str, str] | Non return sorted(endpoints.values(), key=lambda e: e.entity_set_name) -def import_from_json(json_file: Path) -> list[EndpointMetadata]: - """Import endpoints from a raw JSON registry file. +_ROUTE_KEYS = ( + ("api_publisher", "publisher"), + ("api_group", "group"), + ("api_version", "version"), +) + + +def import_from_file(path: Path) -> list[EndpointMetadata]: + """Import endpoints from a JSON or YAML registry file. + + Supports two layouts: + + 1. bcli format — ``{"endpoints": [...]}``. Route keys + (``publisher``/``group``/``version``, or their ``api_*`` spellings) + may be set once at the top level and are inherited by every entry, + and an entry may be just an entity-set name:: + + publisher: contoso + group: integration + version: v1.0 + endpoints: + - shipmentTrackings + - entity_set_name: carrierRates + supports: [GET, POST] + + 2. Grouped format — ``{"": [...], ...}``. - Supports two formats: - 1. bcli format: {"endpoints": [...]} - 2. bcmcp format: {"finance": [...], "technical": [...], ...} + Raises ``ValueError`` if an entry names only part of a custom route. """ - raw = json.loads(json_file.read_text(encoding="utf-8")) - endpoints: list[EndpointMetadata] = [] + text = path.read_text(encoding="utf-8-sig") + if path.suffix.lower() in (".yaml", ".yml"): + raw = yaml.safe_load(text) + else: + raw = json.loads(text) + if not isinstance(raw, dict): + raise ValueError(f"{path}: top level must be a mapping") - # bcli format if "endpoints" in raw: - for entry in raw["endpoints"]: - # Backfill caution from heuristic when the source file omits it. - entry.setdefault("caution", _infer_caution(entry.get("entity_set_name", ""))) - endpoints.append(EndpointMetadata.model_validate(entry)) - return endpoints + return parse_endpoint_list(raw) + return _import_grouped(raw) + + +def import_from_json(json_file: Path) -> list[EndpointMetadata]: + """Import endpoints from a registry file. See :func:`import_from_file`.""" + return import_from_file(json_file) + + +def parse_endpoint_list(raw: dict) -> list[EndpointMetadata]: + defaults: dict[str, str] = {} + for canonical, short in _ROUTE_KEYS: + value = raw.get(canonical) or raw.get(short) + if value: + defaults[canonical] = value + + endpoints: list[EndpointMetadata] = [] + for item in raw["endpoints"] or []: + entry = {"entity_set_name": item} if isinstance(item, str) else dict(item) + for canonical, short in _ROUTE_KEYS: + if short in entry and canonical not in entry: + entry[canonical] = entry.pop(short) + entry = {**defaults, **entry} + name = entry.get("entity_set_name", "") + if not name: + raise ValueError(f"endpoint entry is missing entity_set_name: {item!r}") + + route = [entry.get(canonical) for canonical, _ in _ROUTE_KEYS] + if any(route) and not all(route): + raise ValueError( + f"endpoint '{name}' needs all of publisher, group and version" + " (set them on the entry or once at the top of the file)" + ) + if all(route): + entry.setdefault("entity_name", _singularize(name)) + entry.setdefault("key_field", "systemId") + entry.setdefault("category", entry["api_group"]) + entry.setdefault("caution", _infer_caution(name)) + endpoints.append(EndpointMetadata.model_validate(entry)) + return endpoints + - # bcmcp format (grouped by api_group) +def _import_grouped(raw: dict) -> list[EndpointMetadata]: + endpoints: list[EndpointMetadata] = [] for group_name, items in raw.items(): if not isinstance(items, list): continue @@ -233,22 +298,67 @@ def save_custom_registry( profile_name: str, endpoints: list[EndpointMetadata], source: str = "import", + *, + replace: bool = False, ) -> Path: - """Save imported endpoints as a custom registry for a profile.""" + """Save imported endpoints into a profile's custom registry. + + Merges by entity-set name (case-insensitive) with what is already + there, so importing a second API group keeps the first. With + ``replace=True`` previously imported endpoints are dropped, but + entries installed by a pack (``source_pack``) are kept — the pack's + ledger still owns them. + """ REGISTRIES_DIR.mkdir(parents=True, exist_ok=True) registry_file = REGISTRIES_DIR / f"{profile_name}.json" + merged: dict[str, dict] = {} + if registry_file.is_file(): + existing = json.loads(registry_file.read_text(encoding="utf-8")) + for entry in existing.get("endpoints") or []: + if not isinstance(entry, dict) or not entry.get("entity_set_name"): + continue + if replace and not entry.get("source_pack"): + continue + merged[entry["entity_set_name"].lower()] = entry + for ep in endpoints: + merged[ep.entity_set_name.lower()] = ep.model_dump(exclude_none=True) + data = { "source": source, "imported_at": datetime.now(timezone.utc).isoformat(), - "endpoint_count": len(endpoints), - "endpoints": [ep.model_dump(exclude_none=True) for ep in endpoints], + "endpoint_count": len(merged), + "endpoints": sorted(merged.values(), key=lambda e: e["entity_set_name"].lower()), } registry_file.write_text(json.dumps(data, indent=2)) return registry_file +def export_custom_registry(profile_name: str) -> dict: + """Return a profile's custom endpoints in the portable bcli format. + + The result can be written to a file and imported on another machine + with ``bcli registry import --from-file``. + """ + registry_file = REGISTRIES_DIR / f"{profile_name}.json" + if not registry_file.is_file(): + return {"endpoints": []} + raw = json.loads(registry_file.read_text(encoding="utf-8")) + endpoints = [] + for entry in raw.get("endpoints") or []: + if not isinstance(entry, dict): + continue + meta = EndpointMetadata.model_validate(entry) + if not meta.is_custom: + continue + dumped = meta.model_dump(exclude_none=True) + dumped.pop("source_pack", None) + dumped.pop("pack_version", None) + endpoints.append(dumped) + return {"endpoints": endpoints} + + async def import_from_metadata( transport, environment: str, diff --git a/src/bcli/registry/_schema.py b/src/bcli/registry/_schema.py index c76553c..b6031c2 100644 --- a/src/bcli/registry/_schema.py +++ b/src/bcli/registry/_schema.py @@ -4,7 +4,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field CautionLevel = Literal["low", "medium", "high"] @@ -19,12 +19,19 @@ class EndpointMetadata(BaseModel): key_field: str = "id" category: str = "" - # For standard v2.0 these are None (route is always /api/v2.0/) - api_publisher: str | None = None - api_group: str | None = None - api_version: str | None = None + # For standard v2.0 these are None (route is always /api/v2.0/). + # Hand-written registry files may use the short keys publisher/group/version. + api_publisher: str | None = Field( + default=None, validation_alias=AliasChoices("api_publisher", "publisher"), + ) + api_group: str | None = Field( + default=None, validation_alias=AliasChoices("api_group", "group"), + ) + api_version: str | None = Field( + default=None, validation_alias=AliasChoices("api_version", "version"), + ) - # Domain classification: "standard", "finance", "technical" + # Domain classification used by SafeContext write rules (e.g. "standard", "finance") domain: str = "standard" # Caution level for agent driving — "low" (plain CRUD), "medium" @@ -57,4 +64,4 @@ def route_display(self) -> str: return f"{self.api_publisher}/{self.api_group}/{self.api_version}" return "v2.0 (standard)" - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/src/bcli_cli/_error_handler.py b/src/bcli_cli/_error_handler.py index b429eb5..e6aea52 100644 --- a/src/bcli_cli/_error_handler.py +++ b/src/bcli_cli/_error_handler.py @@ -84,8 +84,8 @@ def _config_init_hint() -> str: def _registry_import_hint() -> str: return ( - "Run 'bcli registry import --from-metadata ' " - "or 'bcli registry import --from-postman ' " + "Run 'bcli registry import --from-metadata --publisher

--group " + "--version ' or 'bcli registry import --from-file ' " "to register the endpoint." ) diff --git a/src/bcli_cli/commands/config_cmd.py b/src/bcli_cli/commands/config_cmd.py index 63a8671..8bea080 100644 --- a/src/bcli_cli/commands/config_cmd.py +++ b/src/bcli_cli/commands/config_cmd.py @@ -307,33 +307,38 @@ def _auth_label(method: str) -> str: def _import_endpoints_for_profile(profile_name: str, import_file: Path) -> None: - """Run a Postman or JSON import for a freshly-created profile. + """Run a registry-file or Postman import for a freshly-created profile. - Detects the format by inspecting the JSON: Postman v2.1 collections have - a top-level ``info`` object plus an ``item`` array, while bcli/bcmcp - registry files have ``endpoints`` or per-group arrays. + Postman v2.1 collections (top-level ``info`` + ``item``) go through the + Postman importer; anything else is read as a JSON or YAML registry file. """ import json from bcli.registry._importers import ( - import_from_json, + import_from_file, import_from_postman, save_custom_registry, ) - try: - raw = json.loads(import_file.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - console.print(f"[red]Could not parse {import_file}:[/red] {e}") - raise typer.Exit(1) from e + is_postman = False + if import_file.suffix.lower() == ".json": + try: + raw = json.loads(import_file.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError as e: + console.print(f"[red]Could not parse {import_file}:[/red] {e}") + raise typer.Exit(1) from e + is_postman = isinstance(raw, dict) and "info" in raw and "item" in raw - is_postman = isinstance(raw, dict) and "info" in raw and "item" in raw if is_postman: endpoints = import_from_postman(import_file) source = "postman" else: - endpoints = import_from_json(import_file) - source = "json" + try: + endpoints = import_from_file(import_file) + except ValueError as e: + console.print(f"[red]Invalid registry file {import_file}:[/red] {e}") + raise typer.Exit(1) from e + source = "file" if not endpoints: console.print(f"[yellow]No endpoints found in {import_file}.[/yellow]") diff --git a/src/bcli_cli/commands/etl_cmd.py b/src/bcli_cli/commands/etl_cmd.py index decf7a0..a67898d 100644 --- a/src/bcli_cli/commands/etl_cmd.py +++ b/src/bcli_cli/commands/etl_cmd.py @@ -32,7 +32,7 @@ def list_entities( if not entities: console.print(f"[yellow]No custom endpoints found for profile '{profile}'.[/yellow]") - console.print("[dim]Import endpoints first: bcli registry import --from-postman [/dim]") + console.print("[dim]Import endpoints first: bcli registry import --from-file [/dim]") raise typer.Exit() table = Table(title=f"ETL Entities — profile: {profile}") @@ -106,7 +106,7 @@ def sync( available = load_entities_from_bcli_registry(profile, custom_only=not include_standard) if not available: console.print(f"[yellow]No custom endpoints found for profile '{profile}'.[/yellow]") - console.print("[dim]Import endpoints first: bcli registry import --from-postman [/dim]") + console.print("[dim]Import endpoints first: bcli registry import --from-file [/dim]") raise typer.Exit() sync_count = len(entity_list) if entity_list else len(available) diff --git a/src/bcli_cli/commands/registry_cmd.py b/src/bcli_cli/commands/registry_cmd.py index 1b3ac73..1b1a681 100644 --- a/src/bcli_cli/commands/registry_cmd.py +++ b/src/bcli_cli/commands/registry_cmd.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import json from pathlib import Path from typing import Optional @@ -9,104 +11,161 @@ from rich.console import Console from bcli.config._defaults import REGISTRIES_DIR +from bcli.exit_codes import EXIT_NOT_FOUND, EXIT_USAGE, EXIT_VALIDATION from bcli.registry._importers import ( - import_from_json, + export_custom_registry, + import_from_file, + import_from_metadata, import_from_postman, save_custom_registry, ) +from bcli.registry._schema import EndpointMetadata from bcli_cli._state import state app = typer.Typer(no_args_is_help=True) console = Console() +def _is_postman_collection(path: Path) -> bool: + if path.suffix.lower() != ".json": + return False + try: + raw = json.loads(path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError: + return False + return isinstance(raw, dict) and "info" in raw and "item" in raw + + +def _load_file(path: Path) -> tuple[list[EndpointMetadata], str]: + if not path.is_file(): + console.print(f"[red]File not found: {path}[/red]") + raise typer.Exit(EXIT_NOT_FOUND) + if _is_postman_collection(path): + console.print(f"[dim]Parsing Postman collection: {path}[/dim]") + return import_from_postman(path), "postman" + console.print(f"[dim]Importing registry file: {path}[/dim]") + try: + return import_from_file(path), "file" + except (ValueError, json.JSONDecodeError) as e: + console.print(f"[red]Invalid registry file {path}:[/red] {e}") + raise typer.Exit(EXIT_VALIDATION) from e + + +def _load_metadata( + publisher: Optional[str], group: Optional[str], version: Optional[str], +) -> list[EndpointMetadata]: + p = state.profile + publisher = publisher or p.api_publisher + group = group or p.api_group + version = version or p.api_version + if not (publisher and group and version): + console.print( + "[red]--from-metadata needs the API route: pass --publisher, --group" + " and --version (the values from your AL API page).[/red]" + ) + raise typer.Exit(EXIT_USAGE) + + console.print(f"[dim]Querying $metadata for {publisher}/{group}/{version}...[/dim]") + + async def _run() -> list[EndpointMetadata]: + async with state.make_async_client() as client: + transport = client._ensure_transport() + return await import_from_metadata( + transport, p.environment, publisher, group, version, + ) + + return asyncio.run(_run()) + + @app.command("import") def import_registry( - from_postman: Optional[Path] = typer.Option(None, "--from-postman", help="Postman v2.1 collection JSON file"), - from_json: Optional[Path] = typer.Option(None, "--from-json", help="Raw registry JSON file (bcli or bcmcp format)"), - from_metadata: bool = typer.Option(False, "--from-metadata", help="Query live BC $metadata endpoint (Phase 2)"), + from_metadata: bool = typer.Option( + False, "--from-metadata", + help="Discover every endpoint of one custom API route from live BC $metadata", + ), + publisher: Optional[str] = typer.Option( + None, "--publisher", help="APIPublisher of your AL API pages (with --from-metadata)", + ), + group: Optional[str] = typer.Option( + None, "--group", help="APIGroup of your AL API pages (with --from-metadata)", + ), + version: Optional[str] = typer.Option( + None, "--version", help="APIVersion of your AL API pages (with --from-metadata)", + ), + from_file: Optional[Path] = typer.Option( + None, "--from-file", + help="Registry file (JSON or YAML) or Postman v2.1 collection", + ), + from_postman: Optional[Path] = typer.Option(None, "--from-postman", hidden=True), + from_json: Optional[Path] = typer.Option(None, "--from-json", hidden=True), + replace: bool = typer.Option( + False, "--replace", + help="Drop previously imported endpoints instead of merging with them", + ), profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to save registry for"), ) -> None: - """Import custom API endpoints from Postman collection, JSON file, or $metadata.""" + """Add custom API endpoints to a profile's registry. + + Imports merge with what is already registered, so you can run this + once per API route. + """ profile_name = profile or state.active_profile_name + path_arg = from_file or from_postman or from_json - if from_postman: - if not from_postman.is_file(): - console.print(f"[red]File not found: {from_postman}[/red]") - raise typer.Exit(1) - - console.print(f"[dim]Parsing Postman collection: {from_postman}[/dim]") - endpoints = import_from_postman(from_postman) - - if not endpoints: - console.print("[yellow]No custom API endpoints found in collection.[/yellow]") - raise typer.Exit(1) - - path = save_custom_registry(profile_name, endpoints, source="postman") - console.print(f"[green]✓[/green] Imported {len(endpoints)} custom endpoints") - console.print(f"[dim]Saved to {path}[/dim]") - - # Show summary by group - groups: dict[str, int] = {} - for ep in endpoints: - key = f"{ep.api_publisher}/{ep.api_group}/{ep.api_version}" - groups[key] = groups.get(key, 0) + 1 - for route, count in sorted(groups.items()): - console.print(f" {route}: {count} endpoints") - - elif from_json: - if not from_json.is_file(): - console.print(f"[red]File not found: {from_json}[/red]") - raise typer.Exit(1) - - console.print(f"[dim]Importing from JSON: {from_json}[/dim]") - endpoints = import_from_json(from_json) - - if not endpoints: - console.print("[yellow]No endpoints found in file.[/yellow]") - raise typer.Exit(1) - - path = save_custom_registry(profile_name, endpoints, source="json") - console.print(f"[green]✓[/green] Imported {len(endpoints)} custom endpoints") - console.print(f"[dim]Saved to {path}[/dim]") - - elif from_metadata: - import asyncio - from bcli.registry._importers import import_from_metadata as _import_meta - - p = state.profile - if not p.api_publisher or not p.api_group or not p.api_version: - console.print( - "[red]--from-metadata requires api_publisher, api_group, and api_version " - "to be set in your profile.[/red]" - ) - console.print("[dim]Set them with: bcli config set profiles..api_publisher [/dim]") - raise typer.Exit(1) + if from_metadata and path_arg: + console.print("[red]Use either --from-metadata or --from-file, not both.[/red]") + raise typer.Exit(EXIT_USAGE) + if from_metadata: + endpoints = _load_metadata(publisher, group, version) + source = "metadata" + elif path_arg: + endpoints, source = _load_file(path_arg) + else: console.print( - f"[dim]Querying $metadata for {p.api_publisher}/{p.api_group}/{p.api_version}...[/dim]" + "[red]Specify --from-metadata --publisher P --group G --version V," + " or --from-file PATH.[/red]" ) + raise typer.Exit(EXIT_USAGE) + + if not endpoints: + console.print("[yellow]No custom API endpoints found.[/yellow]") + raise typer.Exit(EXIT_NOT_FOUND) + + path = save_custom_registry(profile_name, endpoints, source=source, replace=replace) + console.print(f"[green]✓[/green] Imported {len(endpoints)} endpoint(s) into profile '{profile_name}'") + routes: dict[str, int] = {} + for ep in endpoints: + routes[ep.route_display] = routes.get(ep.route_display, 0) + 1 + for route, count in sorted(routes.items()): + console.print(f" {route}: {count}") + console.print(f"[dim]Saved to {path}[/dim]") + + +@app.command("export") +def export_registry( + output: Optional[Path] = typer.Option( + None, "--output", "-o", help="Write to this file instead of stdout", + ), + profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile to export"), +) -> None: + """Export a profile's custom endpoints as a portable registry file. - async def _do_metadata_import(): - async with state.make_async_client() as client: - transport = client._ensure_transport() - return await _import_meta( - transport, p.environment, p.api_publisher, p.api_group, p.api_version, - ) - - endpoints = asyncio.run(_do_metadata_import()) - - if not endpoints: - console.print("[yellow]No endpoints found in $metadata.[/yellow]") - raise typer.Exit(1) - - path = save_custom_registry(profile_name, endpoints, source="metadata") - console.print(f"[green]✓[/green] Discovered {len(endpoints)} endpoints from $metadata") - console.print(f"[dim]Saved to {path}[/dim]") - - else: - console.print("[red]Specify one of: --from-postman, --from-json, --from-metadata[/red]") - raise typer.Exit(1) + Commit the file next to your AL extension or hand it to a teammate; + they load it with `bcli registry import --from-file`. + """ + profile_name = profile or state.active_profile_name + data = export_custom_registry(profile_name) + if not data["endpoints"]: + console.print(f"[yellow]Profile '{profile_name}' has no custom endpoints to export.[/yellow]") + raise typer.Exit(EXIT_NOT_FOUND) + + text = json.dumps(data, indent=2) + "\n" + if output is None: + typer.echo(text, nl=False) + return + output.write_text(text, encoding="utf-8") + console.print(f"[green]✓[/green] Exported {len(data['endpoints'])} endpoint(s) to {output}") @app.command("list") @@ -121,8 +180,6 @@ def list_registries() -> None: console.print("[dim]No custom registries found. Run 'bcli registry import' to add one.[/dim]") return - import json - for f in files: profile_name = f.stem try: diff --git a/tests/test_packs/test_loader.py b/tests/test_packs/test_loader.py index 04965ec..4a33b70 100644 --- a/tests/test_packs/test_loader.py +++ b/tests/test_packs/test_loader.py @@ -91,3 +91,39 @@ def test_load_pack_invalid_yaml(tmp_path: Path) -> None: (src / "pack.yaml").write_text("not: [valid", encoding="utf-8") with pytest.raises(PackLoadError, match="not valid YAML"): load_pack(src) + + +def test_preset_in_registry_export_form(make_pack) -> None: + src = make_pack("demo") + (src / "presets").mkdir() + (src / "presets" / "apis.json").write_text( + '{"publisher": "contoso", "group": "integration", "version": "v1.0",' + ' "endpoints": ["shipmentTrackings", {"entity_set_name": "carrierRates"}]}', + encoding="utf-8", + ) + manifest = (src / "pack.yaml").read_text(encoding="utf-8") + (src / "pack.yaml").write_text( + manifest.replace("contents: {}", "contents:\n registry_presets: [apis.json]"), + encoding="utf-8", + ) + + presets = {p.name: p.body for p in load_pack(src).contents.registry_presets} + + assert set(presets) == {"shipmentTrackings", "carrierRates"} + assert presets["shipmentTrackings"]["api_publisher"] == "contoso" + assert presets["carrierRates"]["api_version"] == "v1.0" + + +def test_preset_with_partial_route_is_rejected(make_pack) -> None: + src = make_pack("demo") + (src / "presets").mkdir() + (src / "presets" / "apis.json").write_text( + '{"publisher": "contoso", "endpoints": ["shipmentTrackings"]}', encoding="utf-8", + ) + manifest = (src / "pack.yaml").read_text(encoding="utf-8") + (src / "pack.yaml").write_text( + manifest.replace("contents: {}", "contents:\n registry_presets: [apis.json]"), + encoding="utf-8", + ) + with pytest.raises(PackLoadError, match="shipmentTrackings"): + load_pack(src) diff --git a/tests/test_registry/test_import_file.py b/tests/test_registry/test_import_file.py new file mode 100644 index 0000000..cd1c3ff --- /dev/null +++ b/tests/test_registry/test_import_file.py @@ -0,0 +1,211 @@ +"""Tests for hand-written registry files, merge-on-import, and export.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from bcli.config._model import BCConfig, BCDefaults, BCProfile +from bcli.registry._importers import ( + export_custom_registry, + import_from_file, + save_custom_registry, +) +from bcli.registry._registry import EndpointRegistry +from bcli.registry._schema import EndpointMetadata +from bcli_cli._state import state +from bcli_cli.app import app + +runner = CliRunner() + +MINIMAL_YAML = """\ +publisher: contoso +group: integration +version: v1.0 +endpoints: + - shipmentTrackings + - entity_set_name: carrierRates + supports: [GET, POST] + description: Carrier rate cards +""" + + +@pytest.fixture +def registries_dir(tmp_path, monkeypatch): + d = tmp_path / "registries" + monkeypatch.setattr("bcli.registry._importers.REGISTRIES_DIR", d) + monkeypatch.setattr("bcli.registry._registry.REGISTRIES_DIR", d) + return d + + +@pytest.fixture +def cli_state(registries_dir): + state._config = BCConfig( + defaults=BCDefaults(profile="dev"), + profiles={"dev": BCProfile(tenant_id="t", environment="Sandbox")}, + ) + state._registry = None + state.profile_name = None + yield registries_dir + state._config = None + state._registry = None + state.profile_name = None + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path + + +class TestImportFromFile: + def test_top_level_route_is_inherited_by_bare_names(self, tmp_path): + endpoints = import_from_file(_write(tmp_path, "apis.yaml", MINIMAL_YAML)) + + by_name = {e.entity_set_name: e for e in endpoints} + assert set(by_name) == {"shipmentTrackings", "carrierRates"} + tracking = by_name["shipmentTrackings"] + assert tracking.route_display == "contoso/integration/v1.0" + assert tracking.entity_name == "shipmentTracking" + assert tracking.key_field == "systemId" + assert tracking.category == "integration" + assert by_name["carrierRates"].supports == ["GET", "POST"] + + def test_entry_route_overrides_file_defaults(self, tmp_path): + text = json.dumps({ + "publisher": "contoso", + "group": "integration", + "version": "v1.0", + "endpoints": [{"entity_set_name": "budgets", "group": "finance", "version": "v2.0"}], + }) + [ep] = import_from_file(_write(tmp_path, "apis.json", text)) + assert ep.route_display == "contoso/finance/v2.0" + + def test_partial_route_is_rejected(self, tmp_path): + text = "publisher: contoso\nendpoints:\n - shipmentTrackings\n" + with pytest.raises(ValueError, match="shipmentTrackings"): + import_from_file(_write(tmp_path, "apis.yml", text)) + + def test_utf8_bom_is_accepted(self, tmp_path): + path = tmp_path / "apis.json" + path.write_bytes( + b"\xef\xbb\xbf" + + json.dumps({"publisher": "c", "group": "g", "version": "v1.0", + "endpoints": ["things"]}).encode() + ) + assert [e.entity_set_name for e in import_from_file(path)] == ["things"] + + +class TestShortRouteKeys: + def test_metadata_accepts_short_keys(self): + ep = EndpointMetadata.model_validate( + {"entity_set_name": "things", "publisher": "c", "group": "g", "version": "v1.0"} + ) + assert ep.is_custom + assert ep.model_dump()["api_publisher"] == "c" + + def test_registry_file_with_short_keys_routes_as_custom(self, registries_dir): + registries_dir.mkdir() + (registries_dir / "dev.json").write_text(json.dumps({"endpoints": [ + {"entity_set_name": "things", "publisher": "c", "group": "g", "version": "v1.0"}, + ]})) + meta = EndpointRegistry(profile_name="dev").resolve("things") + assert meta.route_display == "c/g/v1.0" + + +class TestSaveMerges: + def _custom(self, name: str, group: str) -> EndpointMetadata: + return EndpointMetadata( + entity_set_name=name, api_publisher="contoso", api_group=group, api_version="v1.0", + ) + + def _names(self, path: Path) -> set[str]: + return {e["entity_set_name"] for e in json.loads(path.read_text())["endpoints"]} + + def test_second_import_keeps_first(self, registries_dir): + save_custom_registry("dev", [self._custom("shipmentTrackings", "integration")]) + path = save_custom_registry("dev", [self._custom("budgets", "finance")]) + assert self._names(path) == {"shipmentTrackings", "budgets"} + assert json.loads(path.read_text())["endpoint_count"] == 2 + + def test_reimport_updates_in_place(self, registries_dir): + save_custom_registry("dev", [self._custom("budgets", "finance")]) + path = save_custom_registry("dev", [self._custom("Budgets", "planning")]) + [entry] = json.loads(path.read_text())["endpoints"] + assert entry["api_group"] == "planning" + + def test_replace_drops_imports_but_keeps_pack_entries(self, registries_dir): + registries_dir.mkdir() + (registries_dir / "dev.json").write_text(json.dumps({"endpoints": [ + {"entity_set_name": "oldImport", "api_publisher": "c", "api_group": "g", "api_version": "v1.0"}, + {"entity_set_name": "fromPack", "api_publisher": "c", "api_group": "g", + "api_version": "v1.0", "source_pack": "starter"}, + ]})) + path = save_custom_registry( + "dev", [self._custom("budgets", "finance")], replace=True, + ) + assert self._names(path) == {"fromPack", "budgets"} + + +class TestExport: + def test_export_round_trips_through_import(self, registries_dir, tmp_path): + imported = import_from_file(_write(tmp_path, "apis.yaml", MINIMAL_YAML)) + save_custom_registry("dev", imported) + + exported = export_custom_registry("dev") + out = _write(tmp_path, "export.json", json.dumps(exported)) + again = import_from_file(out) + + assert {e.entity_set_name for e in again} == {"shipmentTrackings", "carrierRates"} + assert all(e.route_display == "contoso/integration/v1.0" for e in again) + + def test_export_strips_pack_provenance(self, registries_dir): + registries_dir.mkdir() + (registries_dir / "dev.json").write_text(json.dumps({"endpoints": [ + {"entity_set_name": "fromPack", "api_publisher": "c", "api_group": "g", + "api_version": "v1.0", "source_pack": "starter", "pack_version": "1.0.0"}, + ]})) + [entry] = export_custom_registry("dev")["endpoints"] + assert "source_pack" not in entry + assert "pack_version" not in entry + + def test_export_of_missing_profile_is_empty(self, registries_dir): + assert export_custom_registry("nobody") == {"endpoints": []} + + +class TestRegistryCli: + def test_import_from_yaml_file(self, cli_state, tmp_path): + path = _write(tmp_path, "apis.yaml", MINIMAL_YAML) + result = runner.invoke(app, ["registry", "import", "--from-file", str(path)]) + assert result.exit_code == 0, result.output + assert "contoso/integration/v1.0: 2" in result.output + assert (cli_state / "dev.json").is_file() + + def test_import_invalid_file_exits_validation(self, cli_state, tmp_path): + path = _write(tmp_path, "apis.yaml", "publisher: contoso\nendpoints: [things]\n") + result = runner.invoke(app, ["registry", "import", "--from-file", str(path)]) + assert result.exit_code == 5 + + def test_from_metadata_without_route_is_usage_error(self, cli_state): + result = runner.invoke(app, ["registry", "import", "--from-metadata"]) + assert result.exit_code == 2 + assert "--publisher" in result.output + + def test_export_to_stdout(self, cli_state, tmp_path): + path = _write(tmp_path, "apis.yaml", MINIMAL_YAML) + runner.invoke(app, ["registry", "import", "--from-file", str(path)]) + result = runner.invoke(app, ["registry", "export"]) + assert result.exit_code == 0, result.output + names = {e["entity_set_name"] for e in json.loads(result.output)["endpoints"]} + assert names == {"shipmentTrackings", "carrierRates"} + + def test_legacy_from_json_flag_still_works(self, cli_state, tmp_path): + path = _write(tmp_path, "apis.json", json.dumps({ + "endpoints": [{"entity_set_name": "things", "api_publisher": "c", + "api_group": "g", "api_version": "v1.0"}], + })) + result = runner.invoke(app, ["registry", "import", "--from-json", str(path)]) + assert result.exit_code == 0, result.output